@skyphusion/search-mcp 0.3.0 → 0.5.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/README.md CHANGED
@@ -6,7 +6,8 @@
6
6
 
7
7
  Open-source toolkit for [Cloudflare AI Search](https://developers.cloudflare.com/ai-search/):
8
8
 
9
- 1. **MCP Worker** (`src/mcp.ts`) -- bearer-gated Streamable-HTTP MCP with a `search` tool for agents.
9
+ 1. **MCP Worker** (`src/mcp.ts`) -- bearer-gated Streamable-HTTP MCP for agents:
10
+ `search`, `list_repos`, `get_file`, `ask`, `corpus_status`, plus `corpus://` resources.
10
11
  2. **Query Worker** (`src/index.ts`) -- CORS + Turnstile + rate-limited `POST /ask` that streams answers for a browser widget.
11
12
  3. **Corpus sync** (`scripts/sync.mjs`, `scripts/sync-runner.mjs`) -- git-tracked sources to R2, with extension remapping so TypeScript, Dockerfiles, and other text AI Search would otherwise skip get indexed.
12
13
 
@@ -48,7 +49,7 @@ cp wrangler.mcp.toml.example wrangler.mcp.toml
48
49
  cp scripts/targets.json.example scripts/targets.json
49
50
  # edit the three files for your account, instance, bucket, and repos
50
51
 
51
- npm run typecheck
52
+ npm run typecheck # src + index.test.ts (Workers types) AND scripts/**/*.test.ts (Node types)
52
53
  npm test
53
54
  ```
54
55
 
@@ -56,25 +57,34 @@ Provision R2 + AI Search, sync your corpus, deploy both Workers. Step-by-step: [
56
57
 
57
58
  ## Skyphusion production
58
59
 
59
- This repo is the production home for Skyphusion AI Search (`search.vivijure.com`,
60
- `search-internal.vivijure.com`). Config is materialized from GitHub Actions secrets at deploy/sync
61
- time; do not commit `wrangler.toml`, `wrangler.mcp.toml`, or `scripts/targets.json`.
60
+ This repo is the production home for three AI Search surfaces:
62
61
 
63
- - [Operator runbook](docs/skyphusion/OPERATOR.md) -- secrets, topology, bootstrap
64
- - [Cutover record (2026-07-08)](docs/skyphusion/CUTOVER.md) -- migration history, failure log, archive steps
62
+ | Surface | Host | Worker config |
63
+ | --- | --- | --- |
64
+ | Public docs / marketing | `search.vivijure.com` | secret `SKYPHUSION_WRANGLER_TOML` → `wrangler.toml` at CI |
65
+ | Internal MCP | `search-internal.vivijure.com` | secret `SKYPHUSION_WRANGLER_MCP_TOML` |
66
+ | Rockenhaus court records | `search.rockenhaus.net` | committed `wrangler.rockenhaus.toml` |
67
+
68
+ Do not commit `wrangler.toml`, `wrangler.mcp.toml`, or `scripts/targets.json` (gitignored).
69
+ Rockenhaus is the exception: its wrangler file is public and tracked.
70
+
71
+ - [Operator runbook](docs/skyphusion/OPERATOR.md) -- secrets, topology shape, escrow, reindex
72
+ - [Cutover record (2026-07-08)](docs/skyphusion/CUTOVER.md) -- migration history (historical)
65
73
 
66
74
  ## Workers
67
75
 
68
76
  | Worker | Entry | Endpoint | Auth |
69
77
  | --- | --- | --- | --- |
70
- | Query | `wrangler.toml` | `POST /ask`, `GET /health` | Turnstile (optional) + CORS allowlist |
71
- | MCP | `wrangler.mcp.toml` | `POST /mcp`, `GET /health` | `Authorization: Bearer` (fail closed) |
78
+ | Query (public) | `wrangler.toml` | `POST /ask`, `GET /health` | Turnstile (optional) + CORS allowlist |
79
+ | MCP (internal) | `wrangler.mcp.toml` | `POST /mcp`, `GET /health` | `Authorization: Bearer` (fail closed) |
80
+ | Query (rockenhaus) | `wrangler.rockenhaus.toml` | `POST /ask`, `GET /health` | CORS allowlist (court-record origins) |
72
81
 
73
- Deploy separately so browser traffic and agent traffic can bind different AI Search instances if you want.
82
+ Deploy separately so browser traffic and agent traffic can bind different AI Search instances.
74
83
 
75
84
  ```sh
76
- npm run deploy # query Worker
77
- npm run deploy:mcp # MCP Worker
85
+ npm run deploy # public query Worker
86
+ npm run deploy:mcp # MCP Worker
87
+ npm run deploy:rockenhaus # rockenhaus query Worker (uses committed wrangler file)
78
88
  wrangler secret put MCP_TOKEN -c wrangler.mcp.toml
79
89
  wrangler secret put TURNSTILE_SECRET # optional; skips verification when unset
80
90
  ```
@@ -95,17 +105,44 @@ wrangler secret put TURNSTILE_SECRET # optional; skips verification when unset
95
105
 
96
106
  `MCP_TOKEN` accepts a single token or comma-separated `name=token` pairs for per-consumer attribution in logs.
97
107
 
108
+ ### MCP tools (agents)
109
+
110
+ | Tool | Role |
111
+ | --- | --- |
112
+ | `list_repos` | Exact repo names for filters (`CORPUS_REPOS` or R2 prefixes) |
113
+ | `search` | Hybrid/keyword/vector chunks; optional `repos`, `path_prefix`, `min_score`, `rewrite` |
114
+ | `get_file` | Capped R2 read by `repo` + `path` (needs `CORPUS` binding) |
115
+ | `ask` | One grounded answer via chatCompletions + source list |
116
+ | `corpus_status` | Last sync metadata (`_meta/corpus-status.json` from `sync.mjs`) |
117
+
118
+ Resources: `corpus://catalog`, `corpus://skill`. Prefer **list_repos → search → get_file**; use **ask** when you want prose rather than raw chunks.
119
+
120
+ Bind the same R2 bucket the sync uploads to as `CORPUS` on the MCP Worker so `get_file` and status work. Optional `CORPUS_REPOS` JSON pins the catalog without listing R2.
121
+
98
122
  ## Corpus sync
99
123
 
100
124
  ```sh
101
125
  export R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... CLOUDFLARE_ACCOUNT_ID=...
102
126
  export CORPUS_GIT_ORG=your-org GITHUB_TOKEN=... # for sync-runner clone auth
103
127
 
104
- npm run sync:dry # plan upload for the default `corpus` target
105
- npm run sync # upload + prune
106
- npm run sync:run # isolated clone root, sync all targets, optional reindex
128
+ npm run sync:dry # plan upload for the default `corpus` target
129
+ npm run sync # upload + prune
130
+ npm run sync:public # skyphusion public target (when targets.json has it)
131
+ npm run sync:internal # skyphusion internal target
132
+ npm run sync:rockenhaus # rockenhaus target
133
+ npm run sync:run # isolated clone root, sync all targets, optional reindex
107
134
  ```
108
135
 
136
+ Useful npm scripts (also `npx` CLIs for the first two after install):
137
+
138
+ | Script | Role |
139
+ | --- | --- |
140
+ | `search-mcp-sync` / `npm run sync` | One-target R2 sync |
141
+ | `search-mcp-sync-run` / `npm run sync:run` | Clone/fetch + multi-target + reindex |
142
+ | `npm run guard:targets` | Additive-only targets.json check |
143
+ | `npm run escrow` | Age-escrow + restore proof for targets secret |
144
+ | `npm run materialize-config` | CI: write wrangler + targets from env secrets |
145
+
109
146
  The sync remaps non-native extensions (`.ts`, `.tsx`, extensionless `Dockerfile`, `.service`, etc.) to `.txt` keys so AI Search indexes them. See `scripts/sync-ingest.mjs`.
110
147
 
111
148
  ### Bounding a corpus: `includePaths` vs `excludePaths`
@@ -115,6 +152,11 @@ repo and it silently joins the corpus. That is fine for a docs site. It is wrong
115
152
  whenever the corpus boundary actually matters, because "we forgot to exclude it"
116
153
  becomes a real incident.
117
154
 
155
+ `includePaths` / `excludePaths` may sit at the **top level** (same rule for every
156
+ target that lists the repo) or **nested under a target** (search-mcp#62). When
157
+ both set the same repo, the per-target map wins for that target only -- so a
158
+ repo can be full-tree on internal and `_corpus/`-only on a public court surface.
159
+
118
160
  `includePaths` is an allowlist and is **fail-closed**: when a repo has an entry,
119
161
  only paths under those prefixes are eligible and everything else is refused.
120
162
 
@@ -201,13 +243,16 @@ before dispatching:
201
243
  2. **The post-job cooldown.** Even once a job ends, a new one is refused for a cooldown window
202
244
  with `sync_in_cooldown [code: 7020]`. Waiting for the job to end is necessary but not
203
245
  sufficient, so we retry until it clears.
246
+ 3. **Transient connect failures.** `unable_to_connect_to_ai_search [code: 7017]` on jobs create
247
+ is retried under the same budget as cooldown (search-mcp#73). Treating it as terminal made
248
+ the whole sync red while R2 already had the new objects.
204
249
 
205
250
  Waiting (rather than skipping) means the job we start always lands strictly after our own
206
251
  upload, so it sees every object this run wrote. Merge bursts still coalesce: a waiting run holds
207
252
  the workflow concurrency group, and GitHub keeps only the newest queued run, so the runs behind
208
253
  it collapse instead of each firing their own reindex.
209
254
 
210
- Each wait has its own budget (10 min in-flight, 10 min cooldown) rather than one shared
255
+ Each wait has its own budget (10 min in-flight, 10 min cooldown/connect) rather than one shared
211
256
  deadline, since the two are additive on a perfectly healthy path: a run can wait minutes for an
212
257
  in-flight reindex and then still owe a cooldown wait.
213
258
 
@@ -258,8 +303,9 @@ Operators building documentation search, agent tooling, or internal knowledge ba
258
303
  ## Links
259
304
 
260
305
  - **Deploy guide:** [docs/DEPLOY.md](docs/DEPLOY.md)
261
- - **Skyphusion Labs:** https://skyphusion.org · **Org:** https://github.com/skyphusion-labs
262
- - **Related:** [vivijure](https://github.com/skyphusion-labs/vivijure), [postern](https://github.com/skyphusion-labs/postern)
306
+ - **Live ask UIs:** [skyphusion.net/search](https://skyphusion.net/search/) · [vivijure.com](https://vivijure.com) (ask widget)
307
+ - **Skyphusion Labs:** https://skyphusion.org · **Blog:** https://skyphusion.net · **Org:** https://github.com/skyphusion-labs
308
+ - **Related:** [vivijure](https://github.com/skyphusion-labs/vivijure), [postern](https://github.com/skyphusion-labs/postern), [security-audit](https://github.com/skyphusion-labs/security-audit), [prism](https://github.com/skyphusion-labs/prism)
263
309
 
264
310
  ## License
265
311
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyphusion/search-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Bearer-gated MCP server and streaming query Worker over Cloudflare AI Search, plus a git-to-R2 corpus sync toolkit.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Conrad Rockenhaus <conrad@skyphusion.org>",
@@ -52,7 +52,7 @@
52
52
  "dev:mcp": "wrangler dev -c wrangler.mcp.toml",
53
53
  "deploy": "wrangler deploy",
54
54
  "deploy:mcp": "wrangler deploy -c wrangler.mcp.toml",
55
- "typecheck": "tsc --noEmit",
55
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json",
56
56
  "test": "vitest run",
57
57
  "prepack": "npm run typecheck && npm test",
58
58
  "sync": "node scripts/sync.mjs corpus",
@@ -62,16 +62,22 @@
62
62
  "sync:run": "node scripts/sync-runner.mjs",
63
63
  "materialize-config": "node scripts/materialize-config.mjs",
64
64
  "escrow": "node scripts/escrow-targets.mjs",
65
- "guard:targets": "node scripts/guard-targets-additive.mjs"
65
+ "guard:targets": "node scripts/guard-targets-additive.mjs",
66
+ "deploy:rockenhaus": "wrangler deploy -c wrangler.rockenhaus.toml",
67
+ "sync:rockenhaus": "node scripts/sync.mjs rockenhaus"
66
68
  },
67
69
  "dependencies": {
68
70
  "@aws-sdk/client-s3": "^3.888.0"
69
71
  },
70
72
  "devDependencies": {
71
73
  "@cloudflare/workers-types": "^5.20260713.1",
74
+ "@types/node": "^22.15.0",
72
75
  "@vitest/coverage-istanbul": "^4.1.10",
73
76
  "typescript": "^7.0.2",
74
77
  "vitest": "^4.1.10",
75
78
  "wrangler": "^4.110.0"
79
+ },
80
+ "overrides": {
81
+ "undici": "7.29.0"
76
82
  }
77
83
  }
@@ -207,6 +207,75 @@ export function knownTargetRepos(cfg) {
207
207
  return repos;
208
208
  }
209
209
 
210
+ /**
211
+ * Resolve include/exclude path maps for ONE target (search-mcp#62).
212
+ *
213
+ * Precedence: per-target maps win per-repo over top-level maps. Top-level alone
214
+ * keeps the pre-#62 shape (the same rule for every target that lists the repo).
215
+ * A repo listed only under another target's nested map is not visible here.
216
+ *
217
+ * Why both layers: the original top-level key was fine while every exclusion
218
+ * applied to exactly one target; the moment a repo is deliberately indexed at
219
+ * two granularities (public vs internal), a single map cannot express it.
220
+ */
221
+ export function pathMapsForTarget(cfg, targetName) {
222
+ const target = cfg?.targets?.[targetName] || {};
223
+ const topInclude =
224
+ cfg?.includePaths && typeof cfg.includePaths === "object" && !Array.isArray(cfg.includePaths)
225
+ ? cfg.includePaths
226
+ : {};
227
+ const topExclude =
228
+ cfg?.excludePaths && typeof cfg.excludePaths === "object" && !Array.isArray(cfg.excludePaths)
229
+ ? cfg.excludePaths
230
+ : {};
231
+ const tInclude =
232
+ target.includePaths && typeof target.includePaths === "object" && !Array.isArray(target.includePaths)
233
+ ? target.includePaths
234
+ : {};
235
+ const tExclude =
236
+ target.excludePaths && typeof target.excludePaths === "object" && !Array.isArray(target.excludePaths)
237
+ ? target.excludePaths
238
+ : {};
239
+ return {
240
+ includePaths: { ...topInclude, ...tInclude },
241
+ excludePaths: { ...topExclude, ...tExclude },
242
+ };
243
+ }
244
+
245
+ function isPathMapObject(v) {
246
+ return v !== undefined && v !== null && typeof v === "object" && !Array.isArray(v);
247
+ }
248
+
249
+ /** Validate one path-map object against a known-repo set. Returns error objects. */
250
+ function validateIncludePathsMap(includePaths, known, label) {
251
+ const errors = [];
252
+ if (includePaths === undefined || includePaths === null) return errors;
253
+ if (!isPathMapObject(includePaths)) {
254
+ errors.push({
255
+ code: "include_paths_not_object",
256
+ message:
257
+ `${label} must be an object keyed by repo, got ${describeType(includePaths)}. ` +
258
+ `Shape: "includePaths": { "my-repo": ["_corpus/"] }.`,
259
+ });
260
+ return errors;
261
+ }
262
+ for (const [repo, prefixes] of Object.entries(includePaths)) {
263
+ if (repo.startsWith("_")) continue; // documentation keys in the example file
264
+ if (!known.has(repo)) {
265
+ errors.push({
266
+ code: "include_paths_unknown_repo",
267
+ message:
268
+ `${label} names repo ${q(repo)}, which is not in the applicable target repo list. ` +
269
+ `Nothing reads that rule, and a rule nothing reads is indistinguishable from a rule ` +
270
+ `that works. Add the repo to the target or delete the entry. Applicable repos: ` +
271
+ `${[...known].join(", ") || "(none)"}.`,
272
+ });
273
+ }
274
+ errors.push(...validateIncludePathsEntry(repo, prefixes));
275
+ }
276
+ return errors;
277
+ }
278
+
210
279
  /** Shape check for one repo entry. Returns {code, message} objects, never throws. */
211
280
  export function validateIncludePathsEntry(repo, prefixes) {
212
281
  const errors = [];
@@ -263,38 +332,24 @@ export function validateIncludePathsEntry(repo, prefixes) {
263
332
  }
264
333
 
265
334
  /**
266
- * Whole-config check for targets.json `includePaths`. Returns {code, message}
267
- * objects, never throws, so a caller can report all of them at once.
335
+ * Whole-config check for targets.json `includePaths` (top-level AND per-target,
336
+ * search-mcp#62). Returns {code, message} objects, never throws, so a caller can
337
+ * report all of them at once.
268
338
  *
269
- * Rejects an entry naming a repo no target lists: a rule nothing reads is
270
- * indistinguishable from a rule that works, which is how an allowlist rots.
339
+ * Rejects an entry naming a repo no applicable target lists: a rule nothing
340
+ * reads is indistinguishable from a rule that works, which is how an allowlist
341
+ * rots. Per-target maps are checked against that target's repos only.
271
342
  */
272
343
  export function validateIncludePathsConfig(cfg) {
273
344
  const errors = [];
274
- const includePaths = cfg?.includePaths;
275
- if (includePaths === undefined || includePaths === null) return errors;
276
- if (typeof includePaths !== "object" || Array.isArray(includePaths)) {
277
- errors.push({
278
- code: "include_paths_not_object",
279
- message:
280
- `targets.json includePaths must be an object keyed by repo, got ` +
281
- `${describeType(includePaths)}. Shape: "includePaths": { "my-repo": ["_corpus/"] }.`,
282
- });
283
- return errors;
284
- }
285
- const known = knownTargetRepos(cfg);
286
- for (const [repo, prefixes] of Object.entries(includePaths)) {
287
- if (!known.has(repo)) {
288
- errors.push({
289
- code: "include_paths_unknown_repo",
290
- message:
291
- `includePaths names repo ${q(repo)}, which no target lists in its repos. Nothing reads ` +
292
- `that rule, and a rule nothing reads is indistinguishable from a rule that works. Add ` +
293
- `the repo to a target or delete the entry. Repos across all targets: ` +
294
- `${[...known].join(", ") || "(none)"}.`,
295
- });
296
- }
297
- errors.push(...validateIncludePathsEntry(repo, prefixes));
345
+ const knownAll = knownTargetRepos(cfg);
346
+ errors.push(...validateIncludePathsMap(cfg?.includePaths, knownAll, "targets.json includePaths"));
347
+ for (const [name, target] of Object.entries(cfg?.targets || {})) {
348
+ if (!target || typeof target !== "object") continue;
349
+ const known = new Set(Array.isArray(target.repos) ? target.repos : []);
350
+ errors.push(
351
+ ...validateIncludePathsMap(target.includePaths, known, `targets.${name}.includePaths`),
352
+ );
298
353
  }
299
354
  return errors;
300
355
  }
@@ -307,15 +362,30 @@ export function assertIncludePathsConfig(cfg) {
307
362
  }
308
363
 
309
364
  /**
310
- * excludePaths keys naming a repo no target lists. Denylist rot is quieter than
311
- * allowlist rot (the corpus grows rather than empties), so this is a warning at
312
- * the call site, not a refusal.
365
+ * excludePaths keys (top-level OR nested under a target) naming a repo no
366
+ * applicable target lists. Denylist rot is quieter than allowlist rot (the
367
+ * corpus grows rather than empties), so this is a warning at the call site,
368
+ * not a refusal. Returns unique repo names.
313
369
  */
314
370
  export function unknownExcludePathsRepos(cfg) {
315
- const excludePaths = cfg?.excludePaths;
316
- if (!excludePaths || typeof excludePaths !== "object" || Array.isArray(excludePaths)) return [];
317
- const known = knownTargetRepos(cfg);
318
- return Object.keys(excludePaths).filter((repo) => !known.has(repo));
371
+ const out = new Set();
372
+ const knownAll = knownTargetRepos(cfg);
373
+ const top = cfg?.excludePaths;
374
+ if (isPathMapObject(top)) {
375
+ for (const repo of Object.keys(top)) {
376
+ if (!repo.startsWith("_") && !knownAll.has(repo)) out.add(repo);
377
+ }
378
+ }
379
+ for (const target of Object.values(cfg?.targets || {})) {
380
+ if (!target || typeof target !== "object") continue;
381
+ const nested = target.excludePaths;
382
+ if (!isPathMapObject(nested)) continue;
383
+ const known = new Set(Array.isArray(target.repos) ? target.repos : []);
384
+ for (const repo of Object.keys(nested)) {
385
+ if (!repo.startsWith("_") && !known.has(repo)) out.add(repo);
386
+ }
387
+ }
388
+ return [...out];
319
389
  }
320
390
 
321
391
  /**
@@ -172,10 +172,23 @@ export const REINDEX_POLL_MS = 15 * 1000;
172
172
  * AI Search refuses a new job for a cooldown window after the previous one ENDS, distinct from
173
173
  * the job being in flight. Measured 2026-07-16: rejected 10s after a job ended, accepted at
174
174
  * 32s. Waiting for `ended_at` alone is necessary but not sufficient.
175
+ *
176
+ * Also treated as transient (2026-08-04 live corpus-sync): `unable_to_connect_to_ai_search
177
+ * [code: 7017]` on jobs create for skyphusion-public -- the instance was healthy (list jobs
178
+ * worked; a later create succeeded). Retrying under the same budget as cooldown is correct;
179
+ * treating 7017 as terminal made the whole sync red while R2 already had the new objects.
175
180
  */
176
- export function isCooldownError(err) {
181
+ export function isTransientReindexError(err) {
177
182
  const text = `${err?.stdout || ""}${err?.stderr || ""}${err?.message || ""}`;
178
- return /sync_in_cooldown|code:\s*7020/.test(text);
183
+ return (
184
+ /sync_in_cooldown|code:\s*7020/.test(text) ||
185
+ /unable_to_connect_to_ai_search|code:\s*7017/.test(text)
186
+ );
187
+ }
188
+
189
+ /** @deprecated prefer isTransientReindexError; kept so older call sites and tests keep working. */
190
+ export function isCooldownError(err) {
191
+ return isTransientReindexError(err);
179
192
  }
180
193
 
181
194
  /**
@@ -218,13 +231,14 @@ export async function awaitReindexSlot(instance, deps) {
218
231
  }
219
232
 
220
233
  /**
221
- * Dispatch a reindex, retrying while AI Search reports its post-job cooldown.
234
+ * Dispatch a reindex, retrying while AI Search reports a TRANSIENT refuse
235
+ * (post-job cooldown 7020, or unable_to_connect_to_ai_search 7017).
222
236
  *
223
- * Cooldown is transient and short (measured well under the budget), so the routine burst case
224
- * clears here and never goes red. Exhausting the budget therefore means something genuinely
225
- * anomalous upstream, and we fail loud rather than exit green: a green run while indexing is
226
- * actually stalled is the work-blind failure mode this whole issue exists to remove. The
227
- * message carries the honest blast radius so the red is actionable, not alarming.
237
+ * These clears are short relative to the budget, so the routine burst case clears here and
238
+ * never goes red. Exhausting the budget means something genuinely anomalous upstream, and we
239
+ * fail loud rather than exit green: a green run while indexing is actually stalled is the
240
+ * work-blind failure mode this whole issue exists to remove. The message carries the honest
241
+ * blast radius so the red is actionable, not alarming.
228
242
  */
229
243
  export async function dispatchWithCooldownRetry(instance, deps) {
230
244
  const {
@@ -242,17 +256,21 @@ export async function dispatchWithCooldownRetry(instance, deps) {
242
256
  runReindexOnce(instance);
243
257
  return { retried: announced };
244
258
  } catch (err) {
245
- if (!isCooldownError(err)) throw err;
259
+ if (!isTransientReindexError(err)) throw err;
246
260
  if (now() >= deadline) {
247
261
  throw new Error(
248
- `reindex for ${instance} blocked by AI Search cooldown for longer than ` +
249
- `${Math.round(timeoutMs / 1000)}s. The R2 corpus uploaded OK and no data is lost; ` +
250
- "the index lags until the next sync or the daily backstop. A cooldown this " +
251
- "persistent is upstream and anomalous, so this run fails loudly on purpose.",
262
+ `reindex for ${instance} blocked by transient AI Search errors for longer than ` +
263
+ `${Math.round(timeoutMs / 1000)}s (cooldown 7020 and/or connect 7017). ` +
264
+ "The R2 corpus uploaded OK and no data is lost; the index lags until the next " +
265
+ "sync or the daily backstop. Persistence this long is upstream and anomalous, " +
266
+ "so this run fails loudly on purpose.",
252
267
  );
253
268
  }
254
269
  if (!announced) {
255
- log(` ${instance} is in AI Search cooldown; retrying until it clears.`);
270
+ log(
271
+ ` ${instance} hit a transient AI Search refuse (cooldown or unable_to_connect); ` +
272
+ "retrying until it clears.",
273
+ );
256
274
  announced = true;
257
275
  }
258
276
  await sleep(pollMs);
package/scripts/sync.mjs CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  selectRepoPaths,
40
40
  assertIncludePathsConfig,
41
41
  unknownExcludePathsRepos,
42
+ pathMapsForTarget,
42
43
  IncludePathsError,
43
44
  } from "./sync-ingest.mjs";
44
45
  import {
@@ -246,13 +247,16 @@ async function main() {
246
247
  console.log(`Target '${targetName}' -> bucket ${target.bucket} (instance ${target.instance})`);
247
248
  console.log(`Repo root: ${REPO_ROOT}${DRY ? " [DRY RUN]" : ""}`);
248
249
 
250
+ // #62: per-target path maps win over top-level for this target.
251
+ const pathMaps = pathMapsForTarget(cfg, targetName);
252
+
249
253
  const plan = [];
250
254
  const skipped = [];
251
255
  for (const repo of target.repos) {
252
256
  const items = planRepo(
253
257
  repo,
254
- cfg.excludePaths?.[repo],
255
- cfg.includePaths?.[repo],
258
+ pathMaps.excludePaths?.[repo],
259
+ pathMaps.includePaths?.[repo],
256
260
  skipped,
257
261
  );
258
262
  if (items.length) console.log(` + ${repo}: ${items.length} files`);
@@ -346,13 +350,36 @@ async function main() {
346
350
  ContinuationToken = res.IsTruncated ? res.NextContinuationToken : undefined;
347
351
  } while (ContinuationToken);
348
352
 
349
- for (let i = 0; i < stale.length; i += 1000) {
350
- const batch = stale.slice(i, i + 1000);
353
+ // Never prune operator/meta keys written for MCP corpus_status.
354
+ const stalePrune = stale.filter((o) => o.Key && !o.Key.startsWith("_meta/"));
355
+ for (let i = 0; i < stalePrune.length; i += 1000) {
356
+ const batch = stalePrune.slice(i, i + 1000);
351
357
  await s3.send(
352
358
  new DeleteObjectsCommand({ Bucket, Delete: { Objects: batch } }),
353
359
  );
354
360
  }
355
- console.log(`Pruned ${stale.length} stale objects.`);
361
+ console.log(`Pruned ${stalePrune.length} stale objects.`);
362
+
363
+ // MCP corpus_status + list_repos freshness (search-mcp agent tooling).
364
+ const status = {
365
+ ts: Date.now(),
366
+ ok: true,
367
+ target: targetName,
368
+ bucket: target.bucket,
369
+ instance: target.instance,
370
+ objectCount: plan.length,
371
+ repos: [...target.repos],
372
+ note: "Written by scripts/sync.mjs after successful upload+prune.",
373
+ };
374
+ await s3.send(
375
+ new PutObjectCommand({
376
+ Bucket,
377
+ Key: "_meta/corpus-status.json",
378
+ Body: Buffer.from(JSON.stringify(status, null, 2), "utf8"),
379
+ ContentType: "application/json",
380
+ }),
381
+ );
382
+ console.log("Wrote _meta/corpus-status.json for MCP corpus_status.");
356
383
  console.log("Done. AI Search will re-index on its next sync.");
357
384
  }
358
385
 
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "_comment": "Copy to targets.json (./targets.json for npm CLI, or scripts/targets.json in a git clone). repos are directory names under SYNC_REPO_ROOT (default: cwd for npm, parent of this repo for dev clone). Only git-tracked files are uploaded.",
3
- "_includePaths_comment": "ALLOWLIST, optional, per repo. When present, ONLY paths under these prefixes are eligible for the corpus and everything else is refused. Use it when the corpus boundary matters: a denylist is fail-OPEN, so a new top-level file silently joins the corpus, whereas an allowlist is fail-CLOSED. Omit a repo to keep the old behaviour of admitting everything; an entry that is present but EMPTY is an error, not a way to index nothing.",
4
- "_includePaths_refusals": "A present allowlist must earn its keep: an entry matching zero git-tracked files, an entry naming a repo no target lists, a repo whose clone is missing, or a selection that excludePaths or the ingest filters empty out all refuse the sync (exit 2). An allowlist that quietly matches nothing would prune a healthy corpus and reindex over the emptiness with every status light green.",
3
+ "_includePaths_comment": "ALLOWLIST, optional, per repo. When present, ONLY paths under these prefixes are eligible for the corpus and everything else is refused. Use it when the corpus boundary matters: a denylist is fail-OPEN, so a new top-level file silently joins the corpus, whereas an allowlist is fail-CLOSED. Omit a repo to keep the old behaviour of admitting everything; an entry that is present but EMPTY is an error, not a way to index nothing. May live at the top level (applies to every target that lists the repo) OR nested under a target (search-mcp#62); per-target wins per repo when both set the same key.",
4
+ "_includePaths_refusals": "A present allowlist must earn its keep: an entry matching zero git-tracked files, an entry naming a repo no applicable target lists, a repo whose clone is missing, or a selection that excludePaths or the ingest filters empty out all refuse the sync (exit 2). An allowlist that quietly matches nothing would prune a healthy corpus and reindex over the emptiness with every status light green.",
5
5
  "includePaths": {
6
6
  "example-repo": ["docs/"]
7
7
  },
8
- "_excludePaths_comment": "DENYLIST, optional, per repo. Applied after includePaths.",
8
+ "_excludePaths_comment": "DENYLIST, optional, per repo. Applied after includePaths. Same top-level vs per-target nesting as includePaths.",
9
9
  "excludePaths": {
10
10
  "example-repo": ["vendor/private-notes/"]
11
11
  },
@@ -15,6 +15,15 @@
15
15
  "instance": "my-ai-search-instance",
16
16
  "bucket": "my-search-corpus",
17
17
  "repos": ["example-repo"]
18
+ },
19
+ "_per_target_paths_example": {
20
+ "_comment": "Optional nested includePaths/excludePaths apply only to this target (search-mcp#62). Use when the same repo is indexed at different granularities across targets.",
21
+ "instance": "other-instance",
22
+ "bucket": "other-bucket",
23
+ "repos": ["example-repo"],
24
+ "includePaths": {
25
+ "example-repo": ["_corpus/"]
26
+ }
18
27
  }
19
28
  }
20
29
  }