@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.
@@ -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