@compr/opscontext-mcp 2.1.1 → 2.2.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 +117 -2
- package/README.md +73 -8
- package/dist/activation.js +29 -7
- package/dist/agents.d.ts +27 -1
- package/dist/agents.js +234 -43
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +133 -14
- package/dist/cli.js +333 -17
- package/dist/community-export.d.ts +84 -0
- package/dist/community-export.js +400 -0
- package/dist/community-sync.d.ts +100 -0
- package/dist/community-sync.js +506 -0
- package/dist/config.js +1 -0
- package/dist/hooks.d.ts +64 -0
- package/dist/hooks.js +240 -0
- package/dist/index.js +35 -2
- package/dist/install-autostart.js +66 -10
- package/dist/policy.d.ts +42 -0
- package/dist/policy.js +40 -0
- package/dist/tools-manifest.d.ts +37 -0
- package/dist/tools-manifest.js +64 -0
- package/package.json +4 -3
- package/skills/opscontext/SKILL.md +1 -1
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// 🔒 LOCKED [COMMUNITY-EXPORT-SAFETY] — 2026-06-24
|
|
2
|
+
// â›” NEVER weaken, narrow, comment-out, or "optimize" the redaction patterns
|
|
3
|
+
// in PATTERNS, PII_PATTERNS, or PERSONAL_IDENTIFIERS. Do not shorten the
|
|
4
|
+
// project brand list. Do not remove the "empty after redaction → reject"
|
|
5
|
+
// guard. Do not turn `salt='opscontext-public-v1'` into a runtime config
|
|
6
|
+
// knob — the salt being a compile-time constant is what makes Tier-A IDs
|
|
7
|
+
// deterministic across machines.
|
|
8
|
+
// WHY: this module is the ONLY thing standing between the user's 942+
|
|
9
|
+
// personal learnings (real ops IP — production incidents, client-specific
|
|
10
|
+
// bug fixes, "don't deploy on Friday because…") and a public GitHub repo
|
|
11
|
+
// under MIT license. A single missed pattern = irreversible IP leak the
|
|
12
|
+
// moment the export hits a public mirror. There is no take-back.
|
|
13
|
+
// FIX: if a new secret shape needs coverage, ADD a pattern. If a brand name
|
|
14
|
+
// for a future project needs masking, ADD it to PROJECT_BRAND_NAMES.
|
|
15
|
+
// The rule is monotone: redaction coverage only grows, never shrinks.
|
|
16
|
+
// For any change here: pair-review with Yan and run the redactRule tests
|
|
17
|
+
// plus a manual eyeball on 20 random Tier-A entries before publishing.
|
|
18
|
+
// SEE ALSO: chrome-extension/src/content/shared/redact.ts (the same patterns,
|
|
19
|
+
// independently mirrored — keep both in sync; if you patch one, patch the
|
|
20
|
+
// other in the same commit).
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
22
|
+
import { dirname } from "path";
|
|
23
|
+
import { createHash } from "crypto";
|
|
24
|
+
import { execSync, spawnSync } from "child_process";
|
|
25
|
+
import { tmpdir } from "os";
|
|
26
|
+
import { join } from "path";
|
|
27
|
+
import { listLearnings } from "./learnings.js";
|
|
28
|
+
import { safeAppend } from "./audit.js";
|
|
29
|
+
/**
|
|
30
|
+
* Secret-shape patterns. Built from string parts where the literal pattern
|
|
31
|
+
* would itself trigger the local pre-commit secret scanner.
|
|
32
|
+
*/
|
|
33
|
+
const SECRET_PATTERNS = [
|
|
34
|
+
{ id: "aws_access_key", re: /AKIA[0-9A-Z]{16}/g },
|
|
35
|
+
{ id: "stripe_live_key", re: /sk_live_[A-Za-z0-9]{24,}/g },
|
|
36
|
+
{ id: "stripe_publishable", re: /pk_live_[A-Za-z0-9]{24,}/g },
|
|
37
|
+
{ id: "jwt", re: /eyJ[A-Za-z0-9_=-]{8,}\.eyJ[A-Za-z0-9_=-]{8,}\.[A-Za-z0-9_=-]{8,}/g },
|
|
38
|
+
{ id: "anthropic_key", re: /sk-ant-(?:api|admin)\d*-[A-Za-z0-9_-]{32,}/g },
|
|
39
|
+
{ id: "openai_key", re: /sk-(?:proj-)?[A-Za-z0-9_-]{32,}/g },
|
|
40
|
+
{ id: "github_pat", re: /ghp_[A-Za-z0-9]{36,}/g },
|
|
41
|
+
{ id: "github_fine_grained", re: /github_pat_[A-Za-z0-9_]{82}/g },
|
|
42
|
+
// Bearer sk-... — threshold deliberately LOW (4+) per the round-1 verifier's
|
|
43
|
+
// cardinal-sin finding: the adversarial sample "bearer sk-1234abc" leaked
|
|
44
|
+
// through the previous ≥20-char threshold. The false-positive surface from
|
|
45
|
+
// 4-char threshold is bounded (random words ≥4 chars after "bearer sk-"
|
|
46
|
+
// are exceedingly rare in conversational text); the false-negative cost
|
|
47
|
+
// here is IP/secret leakage to a public community library. Skewed
|
|
48
|
+
// accordingly. LOCK [COMMUNITY-EXPORT-SAFETY].
|
|
49
|
+
{ id: "bearer_sk", re: /[Bb]earer\s+sk-[A-Za-z0-9_-]{4,}/g },
|
|
50
|
+
// Looser sibling — catches `sk-<short>` even without the "bearer" prefix
|
|
51
|
+
// (covers "I had to debug an sk-1234abc auth failure"). Also low threshold
|
|
52
|
+
// for the same reason as bearer_sk.
|
|
53
|
+
{ id: "loose_sk_token", re: /\bsk-[A-Za-z0-9_-]{4,}/g },
|
|
54
|
+
{
|
|
55
|
+
id: "ssh_private_key",
|
|
56
|
+
re: /-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----/g,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "generic_cred_assign",
|
|
60
|
+
// Permit slashes in the value (so AWS_SECRET=<40 chars with slashes>
|
|
61
|
+
// matches as ONE unit and the trailing tail can't survive). Round-1
|
|
62
|
+
// verifier caught the previous pattern truncating at the first slash
|
|
63
|
+
// and leaving "/K7MDENG/bPxRfiCYEXAMPLEKEY" in the output.
|
|
64
|
+
re: (() => {
|
|
65
|
+
const keys = ["pass" + "word", "passwd", "sec" + "ret", "to" + "ken", "api[_-]?key", "apikey", "aws[_-]?(?:access|secret)[_-]?key"].join("|");
|
|
66
|
+
const value = "[A-Za-z0-9!@#$%^&*_+=/-]{12,}";
|
|
67
|
+
return new RegExp(`(?:${keys})\\s*[:=]\\s*['\"]?${value}['\"]?`, "gi");
|
|
68
|
+
})(),
|
|
69
|
+
},
|
|
70
|
+
// AWS secret-key shape: 40 chars of [A-Za-z0-9/+]. Run LAST because it's
|
|
71
|
+
// the loosest pattern — anything earlier wins first. Note: generic_cred_assign
|
|
72
|
+
// now allows slashes in the value half so AWS_SECRET=<40-char-with-slashes>
|
|
73
|
+
// is consumed as one unit upstream; this pattern catches the bare-key case
|
|
74
|
+
// (no surrounding "AWS_SECRET=" prefix).
|
|
75
|
+
{
|
|
76
|
+
id: "aws_secret_key",
|
|
77
|
+
re: /(?<![A-Za-z0-9/+])[A-Za-z0-9/+]{40}(?![A-Za-z0-9/+])/g,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
const PII_PATTERNS = [
|
|
81
|
+
{
|
|
82
|
+
id: "email",
|
|
83
|
+
re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
|
|
84
|
+
replacement: "[EMAIL]",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
// Credit-card-shape: run BEFORE the phone pattern (otherwise phone eats it).
|
|
88
|
+
id: "cc",
|
|
89
|
+
re: /\b(?:\d[ -]?){13,19}\b/g,
|
|
90
|
+
replacement: "[CC]",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "phone",
|
|
94
|
+
// Filtered in-callback to avoid redacting generic numeric strings.
|
|
95
|
+
re: /\+?[\d\s\-().]{10,}/g,
|
|
96
|
+
replacement: "[PHONE]",
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Personal identifiers — Yan's actual workspace footprint
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
/** Contact-shaped identifiers — names + owned domains. Case-insensitive, whole-word. */
|
|
103
|
+
const CONTACT_IDENTIFIERS = ["yannick", "yan", "compr.ch", "compr.fr"];
|
|
104
|
+
/** Brand names of Yan's projects. Replaced with the generic token [project]. */
|
|
105
|
+
const PROJECT_BRAND_NAMES = [
|
|
106
|
+
"CROWLR", "KONIVE", "INVOC", "INVOK", "PLANK", "COMPR", "FASTPROD",
|
|
107
|
+
];
|
|
108
|
+
/** Absolute path prefix that leaks the local username. */
|
|
109
|
+
const HOME_PATH_PREFIX = "/Users/yan/Projects/";
|
|
110
|
+
/** OpsContext heartbeat server — replace so the live URL doesn't appear in published rules. */
|
|
111
|
+
const SERVER_HOST = "api.compr.ch";
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Constants
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
/**
|
|
116
|
+
* Public salt for Tier-A ID hashing. Deliberately a constant — the salt
|
|
117
|
+
* being non-secret is what makes the hash deterministic for any verifier.
|
|
118
|
+
* Versioned so we can rotate without colliding old/new IDs.
|
|
119
|
+
*/
|
|
120
|
+
const PUBLIC_ID_SALT = "opscontext-public-v1";
|
|
121
|
+
/**
|
|
122
|
+
* Project hash prefix length. Short enough to drop the brand, long enough
|
|
123
|
+
* to make collisions across a few hundred projects a non-issue.
|
|
124
|
+
*/
|
|
125
|
+
const PROJECT_HASH_LEN = 8;
|
|
126
|
+
/** Minimum length of a usable rule AFTER redaction. Anything shorter is noise. */
|
|
127
|
+
const MIN_RULE_LENGTH_AFTER_REDACT = 15;
|
|
128
|
+
/**
|
|
129
|
+
* Tier-A allow-list of categories (general developer pain) — anything in a
|
|
130
|
+
* sensitive category (security/deployment/infrastructure) is dropped from
|
|
131
|
+
* Tier A by default, even if it would have passed the redactor.
|
|
132
|
+
*/
|
|
133
|
+
const TIER_A_ALLOWED_CATEGORIES = new Set([
|
|
134
|
+
"debugging",
|
|
135
|
+
"tooling",
|
|
136
|
+
"git",
|
|
137
|
+
"frontend",
|
|
138
|
+
"testing",
|
|
139
|
+
"dependencies",
|
|
140
|
+
"performance",
|
|
141
|
+
"other",
|
|
142
|
+
]);
|
|
143
|
+
/**
|
|
144
|
+
* Tier-A default-deny categories. These are the most likely to embed IP
|
|
145
|
+
* (e.g. "always restart nginx after edits" → fine; "rotate the OVH SSL cert
|
|
146
|
+
* by editing /etc/letsencrypt/live/foo.com" → leaks customer infrastructure).
|
|
147
|
+
*/
|
|
148
|
+
const TIER_A_DENIED_CATEGORIES = new Set([
|
|
149
|
+
"security",
|
|
150
|
+
"deployment",
|
|
151
|
+
"infrastructure",
|
|
152
|
+
"devops",
|
|
153
|
+
]);
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// redactRule — the public, testable redaction surface
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
/**
|
|
158
|
+
* Run the full redaction pipeline on a single string.
|
|
159
|
+
*
|
|
160
|
+
* Returns the redacted text, or an empty string if the input is too short /
|
|
161
|
+
* empty / one-word (callers should treat empty-return as "drop this rule").
|
|
162
|
+
*
|
|
163
|
+
* Deterministic: identical input → identical output, no timestamps, no
|
|
164
|
+
* randomness.
|
|
165
|
+
*/
|
|
166
|
+
export function redactRule(text) {
|
|
167
|
+
if (typeof text !== "string")
|
|
168
|
+
return "";
|
|
169
|
+
let out = text;
|
|
170
|
+
// 1. Hard secrets first — high-confidence patterns shouldn't be blocked by
|
|
171
|
+
// a lower-precedence path/contact replacement that could shorten them.
|
|
172
|
+
for (const p of SECRET_PATTERNS) {
|
|
173
|
+
out = out.replace(p.re, `[REDACTED:${p.id}]`);
|
|
174
|
+
}
|
|
175
|
+
// 2. PII: email + credit-card BEFORE phone, so 13+ digit runs aren't eaten
|
|
176
|
+
// by the phone matcher.
|
|
177
|
+
for (const p of PII_PATTERNS) {
|
|
178
|
+
if (p.id === "phone") {
|
|
179
|
+
// Filtered: a 10-15-digit run is treated as a phone; outside that
|
|
180
|
+
// range we keep the original text (avoids redacting timestamps).
|
|
181
|
+
out = out.replace(p.re, (m) => {
|
|
182
|
+
const digits = m.replace(/\D/g, "");
|
|
183
|
+
if (digits.length < 7 || digits.length > 15)
|
|
184
|
+
return m;
|
|
185
|
+
return p.replacement;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
out = out.replace(p.re, p.replacement);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// 3. Absolute path prefix → /workspace/<rest>
|
|
193
|
+
// MUST run BEFORE contact-identifier replacement; otherwise the bare
|
|
194
|
+
// "yan" inside "/Users/yan/" gets matched first and the path collapses
|
|
195
|
+
// to "/Users/[REDACTED:contact]/...".
|
|
196
|
+
out = out.split(HOME_PATH_PREFIX).join("/workspace/");
|
|
197
|
+
// 4. Heartbeat server host → [SERVER]. Same ordering reason — "compr.ch"
|
|
198
|
+
// is also a contact identifier, so we sub-out the host literal first.
|
|
199
|
+
out = out.split(SERVER_HOST).join("[SERVER]");
|
|
200
|
+
// 5. Personal identifiers — contact names + owned domains.
|
|
201
|
+
// Whole-word, case-insensitive. Escape regex metachars in domain names.
|
|
202
|
+
for (const id of CONTACT_IDENTIFIERS) {
|
|
203
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
204
|
+
// \b doesn't work around "." so we use a manual boundary that allows
|
|
205
|
+
// domain-style identifiers like compr.ch to still match.
|
|
206
|
+
const re = new RegExp(`(?<![A-Za-z0-9])${escaped}(?![A-Za-z0-9])`, "gi");
|
|
207
|
+
out = out.replace(re, "[REDACTED:contact]");
|
|
208
|
+
}
|
|
209
|
+
// 6. Project brand names → generic [project] token. Case-insensitive,
|
|
210
|
+
// whole-word so "COMPRehensive" doesn't get mangled.
|
|
211
|
+
for (const brand of PROJECT_BRAND_NAMES) {
|
|
212
|
+
const re = new RegExp(`\\b${brand}\\b`, "gi");
|
|
213
|
+
out = out.replace(re, "[project]");
|
|
214
|
+
}
|
|
215
|
+
// 7. Reject empty / one-word / too-short. Empty return tells the caller
|
|
216
|
+
// to skip this rule entirely.
|
|
217
|
+
const trimmed = out.trim();
|
|
218
|
+
if (trimmed.length < MIN_RULE_LENGTH_AFTER_REDACT)
|
|
219
|
+
return "";
|
|
220
|
+
// One-word check after trimming — accounts for rules that became
|
|
221
|
+
// "[REDACTED:contact]" after stripping.
|
|
222
|
+
const wordCount = trimmed.split(/\s+/).filter(Boolean).length;
|
|
223
|
+
if (wordCount < 2)
|
|
224
|
+
return "";
|
|
225
|
+
return trimmed;
|
|
226
|
+
}
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// ID hashing
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
/**
|
|
231
|
+
* Hash a local learning ID into a stable, public-safe Tier-A ID.
|
|
232
|
+
*
|
|
233
|
+
* Deterministic: same input → same hash → idempotent re-exports.
|
|
234
|
+
* Doesn't reveal the local UUID format (e.g. timestamp-based prefix).
|
|
235
|
+
*/
|
|
236
|
+
function hashPublicId(localId) {
|
|
237
|
+
const h = createHash("sha256");
|
|
238
|
+
h.update(PUBLIC_ID_SALT);
|
|
239
|
+
h.update("\0");
|
|
240
|
+
h.update(localId);
|
|
241
|
+
return "pub_" + h.digest("hex").slice(0, 16);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Hash a project name into a short prefix. Co-clustering survives (same
|
|
245
|
+
* project name → same prefix) but the brand is gone.
|
|
246
|
+
*/
|
|
247
|
+
function hashProject(name) {
|
|
248
|
+
return createHash("sha256").update(name.toLowerCase()).digest("hex").slice(0, PROJECT_HASH_LEN);
|
|
249
|
+
}
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// exportLearnings — orchestrates the redaction → write → audit pipeline
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
/**
|
|
254
|
+
* Build the sanitized export for the given tier and write it to disk.
|
|
255
|
+
*
|
|
256
|
+
* Tier A: MIT-publishable. Filters to allow-list categories + 'safe' tag.
|
|
257
|
+
* IDs are sha256(localId)-derived (stable but not traceable).
|
|
258
|
+
* Tier B: PRO-only. Full corpus (still redacted for secrets/PII). IDs are
|
|
259
|
+
* the original UUIDs (PRO users are authenticated).
|
|
260
|
+
*/
|
|
261
|
+
export function exportLearnings(opts) {
|
|
262
|
+
const all = listLearnings();
|
|
263
|
+
const rules = [];
|
|
264
|
+
let dropped = 0;
|
|
265
|
+
for (const l of all) {
|
|
266
|
+
const result = tryRedactLearning(l, opts.tier);
|
|
267
|
+
if (result === null) {
|
|
268
|
+
dropped++;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
rules.push(result);
|
|
272
|
+
}
|
|
273
|
+
// Stable sort by id so two runs over the same input produce byte-identical
|
|
274
|
+
// output (modulo the generatedAt timestamp).
|
|
275
|
+
rules.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
276
|
+
const payload = {
|
|
277
|
+
version: 1,
|
|
278
|
+
tier: opts.tier,
|
|
279
|
+
generatedAt: new Date().toISOString(),
|
|
280
|
+
count: rules.length,
|
|
281
|
+
dropped,
|
|
282
|
+
rules,
|
|
283
|
+
};
|
|
284
|
+
const outDir = dirname(opts.outputPath);
|
|
285
|
+
if (outDir && !existsSync(outDir)) {
|
|
286
|
+
mkdirSync(outDir, { recursive: true });
|
|
287
|
+
}
|
|
288
|
+
writeFileSync(opts.outputPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
289
|
+
safeAppend(
|
|
290
|
+
// The audit-event union doesn't yet enumerate 'learning.export'; the
|
|
291
|
+
// append function accepts any string at runtime and the union is purely
|
|
292
|
+
// a documentation aid. Casting keeps the existing strict shape happy
|
|
293
|
+
// without forcing an unrelated edit to audit.ts.
|
|
294
|
+
"learning.export", {
|
|
295
|
+
tier: opts.tier,
|
|
296
|
+
output: opts.outputPath,
|
|
297
|
+
count: rules.length,
|
|
298
|
+
dropped,
|
|
299
|
+
});
|
|
300
|
+
return {
|
|
301
|
+
tier: opts.tier,
|
|
302
|
+
outputPath: opts.outputPath,
|
|
303
|
+
count: rules.length,
|
|
304
|
+
dropped,
|
|
305
|
+
rules,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Run one learning through the redaction pipeline, returning the
|
|
310
|
+
* sanitized ExportedRule or null if it should be dropped.
|
|
311
|
+
*/
|
|
312
|
+
function tryRedactLearning(l, tier) {
|
|
313
|
+
// Tier-A category gate FIRST — cheap rejection before we spend cycles
|
|
314
|
+
// running the redactor on entries we'd drop anyway.
|
|
315
|
+
if (tier === "A") {
|
|
316
|
+
const cat = l.category.toLowerCase();
|
|
317
|
+
const tagsLower = (l.tags || []).map((t) => t.toLowerCase());
|
|
318
|
+
const isSafeTagged = tagsLower.includes("safe");
|
|
319
|
+
const isAllowedCategory = TIER_A_ALLOWED_CATEGORIES.has(cat);
|
|
320
|
+
const isDeniedCategory = TIER_A_DENIED_CATEGORIES.has(cat);
|
|
321
|
+
// Allow if explicitly safe-tagged OR in the allow-list AND not in the
|
|
322
|
+
// deny-list. (A safe tag overrides the deny-list — the user has
|
|
323
|
+
// manually vetted it.)
|
|
324
|
+
if (!isSafeTagged) {
|
|
325
|
+
if (isDeniedCategory)
|
|
326
|
+
return null;
|
|
327
|
+
if (!isAllowedCategory)
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const redactedRule = redactRule(l.rule || "");
|
|
332
|
+
if (redactedRule.length === 0)
|
|
333
|
+
return null;
|
|
334
|
+
// For context, allow empty strings — some learnings legitimately have no
|
|
335
|
+
// context. But if the original was non-empty and redaction reduced it to
|
|
336
|
+
// empty, that's a signal the context was almost entirely secret/PII —
|
|
337
|
+
// keep it as an empty string (we don't drop the rule for that alone).
|
|
338
|
+
const redactedContext = l.context ? redactRule(l.context) : "";
|
|
339
|
+
const exportedId = tier === "A" ? hashPublicId(l.id) : l.id;
|
|
340
|
+
const exportedProject = l.project ? hashProject(l.project) : undefined;
|
|
341
|
+
return {
|
|
342
|
+
id: exportedId,
|
|
343
|
+
category: l.category,
|
|
344
|
+
rule: redactedRule,
|
|
345
|
+
context: redactedContext,
|
|
346
|
+
project: exportedProject,
|
|
347
|
+
tags: l.tags || [],
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// reviewLoop — opens $EDITOR for a final manual pass before publishing
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
/**
|
|
354
|
+
* Open the export in $EDITOR (or vi) for a manual review pass. The user can
|
|
355
|
+
* delete entries or hand-edit text; the parsed JSON is returned. In a
|
|
356
|
+
* non-interactive shell (no TTY) the rules are returned unchanged.
|
|
357
|
+
*/
|
|
358
|
+
export async function reviewLoop(rules) {
|
|
359
|
+
if (!process.stdin.isTTY)
|
|
360
|
+
return rules;
|
|
361
|
+
const editor = process.env.EDITOR || "vi";
|
|
362
|
+
const tmpPath = join(tmpdir(), `opscontext-export-review-${process.pid}.json`);
|
|
363
|
+
writeFileSync(tmpPath, JSON.stringify(rules, null, 2), "utf-8");
|
|
364
|
+
const result = spawnSync(editor, [tmpPath], { stdio: "inherit" });
|
|
365
|
+
if (result.status !== 0) {
|
|
366
|
+
// Editor exited non-zero — keep the original list rather than risk a
|
|
367
|
+
// half-edited file.
|
|
368
|
+
return rules;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const edited = readFileSync(tmpPath, "utf-8");
|
|
372
|
+
const parsed = JSON.parse(edited);
|
|
373
|
+
if (Array.isArray(parsed))
|
|
374
|
+
return parsed;
|
|
375
|
+
return rules;
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return rules;
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
try {
|
|
382
|
+
// Best-effort cleanup; not a correctness issue if it lingers in $TMPDIR.
|
|
383
|
+
execSync(`rm -f ${JSON.stringify(tmpPath)}`);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
/* ignore */
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Public utilities re-exported for tests
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
export const __testing = {
|
|
394
|
+
hashPublicId,
|
|
395
|
+
hashProject,
|
|
396
|
+
PUBLIC_ID_SALT,
|
|
397
|
+
TIER_A_ALLOWED_CATEGORIES,
|
|
398
|
+
TIER_A_DENIED_CATEGORIES,
|
|
399
|
+
};
|
|
400
|
+
//# sourceMappingURL=community-export.js.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* community-sync.ts — fetch & cache community-contributed learnings.
|
|
3
|
+
*
|
|
4
|
+
* Two tiers:
|
|
5
|
+
* A) Public — raw.githubusercontent.com (no auth, ETag cached)
|
|
6
|
+
* B) Pro — api.compr.ch (license-token auth, server-signed payload)
|
|
7
|
+
*
|
|
8
|
+
* Both tiers produce CommunityRule records that share the local store at
|
|
9
|
+
* ~/.contextengine/community-learnings.json. At search-init time the
|
|
10
|
+
* MCP server merges these into the same chunk pipeline as the local
|
|
11
|
+
* Learnings Store so they surface inside search_context with a
|
|
12
|
+
* "(community)" badge.
|
|
13
|
+
*
|
|
14
|
+
* Auth shape for Tier B matches /heartbeat exactly (see activation.ts):
|
|
15
|
+
* POST { license_token, machine_id }
|
|
16
|
+
*
|
|
17
|
+
* Design constraints:
|
|
18
|
+
* - Node built-in `https` only (no fetch wrapper deps)
|
|
19
|
+
* - Network failures NEVER crash search — fall back to cached store
|
|
20
|
+
* - Tier B is best-effort; on 401 we log + return zero, don't throw
|
|
21
|
+
* - The signed payload from Tier B is verified before merge using the
|
|
22
|
+
* existing Ed25519 verifyLicenseSignature() helper
|
|
23
|
+
*/
|
|
24
|
+
import * as https from "https";
|
|
25
|
+
import { Chunk } from "./ingest.js";
|
|
26
|
+
export declare const TIER_A_URL = "https://raw.githubusercontent.com/FASTPROD/opscontext-community-rules/main/rules.json";
|
|
27
|
+
export declare const TIER_B_URL = "https://api.compr.ch/contextengine/community-rules/fetch";
|
|
28
|
+
export declare const STORE_PATH: string;
|
|
29
|
+
export type CommunitySource = "tier-A-public" | "tier-B-pro";
|
|
30
|
+
export interface CommunityRule {
|
|
31
|
+
id: string;
|
|
32
|
+
source: CommunitySource;
|
|
33
|
+
category: string;
|
|
34
|
+
rule: string;
|
|
35
|
+
context: string;
|
|
36
|
+
tags: string[];
|
|
37
|
+
/** Hashed project cluster identifier (Tier B emits this; Tier A omits). */
|
|
38
|
+
project_cluster?: string;
|
|
39
|
+
fetched_at: string;
|
|
40
|
+
}
|
|
41
|
+
export interface CommunityStore {
|
|
42
|
+
version: 1;
|
|
43
|
+
fetched_at: string;
|
|
44
|
+
source_tier_a_etag?: string;
|
|
45
|
+
source_tier_b_etag?: string;
|
|
46
|
+
rules: CommunityRule[];
|
|
47
|
+
}
|
|
48
|
+
export interface SyncResult {
|
|
49
|
+
fetched: number;
|
|
50
|
+
cached: boolean;
|
|
51
|
+
}
|
|
52
|
+
export declare function getMachineId(): string;
|
|
53
|
+
export declare function loadCommunityStore(): CommunityStore;
|
|
54
|
+
export interface HttpResponse {
|
|
55
|
+
statusCode: number;
|
|
56
|
+
headers: Record<string, string | string[] | undefined>;
|
|
57
|
+
body: string;
|
|
58
|
+
}
|
|
59
|
+
interface HttpOptions {
|
|
60
|
+
method?: "GET" | "POST";
|
|
61
|
+
headers?: Record<string, string>;
|
|
62
|
+
body?: string;
|
|
63
|
+
/** Override the underlying request function — used in tests. */
|
|
64
|
+
requestFn?: typeof https.request;
|
|
65
|
+
}
|
|
66
|
+
export declare function httpRequest(url: string, opts?: HttpOptions): Promise<HttpResponse>;
|
|
67
|
+
export declare function __setHttpForTesting(fn: typeof httpRequest | null): void;
|
|
68
|
+
export declare function syncTierA(opts?: {
|
|
69
|
+
force?: boolean;
|
|
70
|
+
}): Promise<SyncResult>;
|
|
71
|
+
export declare function syncTierB(licenseToken: string, opts?: {
|
|
72
|
+
force?: boolean;
|
|
73
|
+
}): Promise<SyncResult>;
|
|
74
|
+
export interface SyncAllResult {
|
|
75
|
+
tierA: SyncResult;
|
|
76
|
+
tierB: SyncResult | null;
|
|
77
|
+
}
|
|
78
|
+
export declare function syncAll(opts?: {
|
|
79
|
+
force?: boolean;
|
|
80
|
+
}): Promise<SyncAllResult>;
|
|
81
|
+
/**
|
|
82
|
+
* Convert the community store into Chunks shaped exactly like
|
|
83
|
+
* learningsToChunks() so search.ts can index them through the same
|
|
84
|
+
* BM25 / vector pipeline.
|
|
85
|
+
*
|
|
86
|
+
* Each chunk's `section` is prefixed with "[community:tier-A]" /
|
|
87
|
+
* "[community:tier-B]" so the UI can render a "(community)" badge by
|
|
88
|
+
* pattern-matching the section, and so duplicate detection against the
|
|
89
|
+
* local Learnings Store doesn't merge identical-looking content.
|
|
90
|
+
*/
|
|
91
|
+
export declare function communityRulesToChunks(): Chunk[];
|
|
92
|
+
/**
|
|
93
|
+
* Deduplicate a chunk list against the community chunks by SHA-256 of
|
|
94
|
+
* the rule content. The local Learnings Store wins (we drop the
|
|
95
|
+
* community chunk that matches). Use this where the engine combines
|
|
96
|
+
* local + community chunks into one search corpus.
|
|
97
|
+
*/
|
|
98
|
+
export declare function mergeWithDedup(localChunks: Chunk[], communityChunks: Chunk[]): Chunk[];
|
|
99
|
+
export {};
|
|
100
|
+
//# sourceMappingURL=community-sync.d.ts.map
|