@skyphusion/search-mcp 0.2.1 → 0.4.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
@@ -48,7 +48,7 @@ cp wrangler.mcp.toml.example wrangler.mcp.toml
48
48
  cp scripts/targets.json.example scripts/targets.json
49
49
  # edit the three files for your account, instance, bucket, and repos
50
50
 
51
- npm run typecheck
51
+ npm run typecheck # src + index.test.ts (Workers types) AND scripts/**/*.test.ts (Node types)
52
52
  npm test
53
53
  ```
54
54
 
@@ -56,25 +56,34 @@ Provision R2 + AI Search, sync your corpus, deploy both Workers. Step-by-step: [
56
56
 
57
57
  ## Skyphusion production
58
58
 
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`.
59
+ This repo is the production home for three AI Search surfaces:
62
60
 
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
61
+ | Surface | Host | Worker config |
62
+ | --- | --- | --- |
63
+ | Public docs / marketing | `search.vivijure.com` | secret `SKYPHUSION_WRANGLER_TOML` → `wrangler.toml` at CI |
64
+ | Internal MCP | `search-internal.vivijure.com` | secret `SKYPHUSION_WRANGLER_MCP_TOML` |
65
+ | Rockenhaus court records | `search.rockenhaus.net` | committed `wrangler.rockenhaus.toml` |
66
+
67
+ Do not commit `wrangler.toml`, `wrangler.mcp.toml`, or `scripts/targets.json` (gitignored).
68
+ Rockenhaus is the exception: its wrangler file is public and tracked.
69
+
70
+ - [Operator runbook](docs/skyphusion/OPERATOR.md) -- secrets, topology shape, escrow, reindex
71
+ - [Cutover record (2026-07-08)](docs/skyphusion/CUTOVER.md) -- migration history (historical)
65
72
 
66
73
  ## Workers
67
74
 
68
75
  | Worker | Entry | Endpoint | Auth |
69
76
  | --- | --- | --- | --- |
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) |
77
+ | Query (public) | `wrangler.toml` | `POST /ask`, `GET /health` | Turnstile (optional) + CORS allowlist |
78
+ | MCP (internal) | `wrangler.mcp.toml` | `POST /mcp`, `GET /health` | `Authorization: Bearer` (fail closed) |
79
+ | Query (rockenhaus) | `wrangler.rockenhaus.toml` | `POST /ask`, `GET /health` | CORS allowlist (court-record origins) |
72
80
 
73
- Deploy separately so browser traffic and agent traffic can bind different AI Search instances if you want.
81
+ Deploy separately so browser traffic and agent traffic can bind different AI Search instances.
74
82
 
75
83
  ```sh
76
- npm run deploy # query Worker
77
- npm run deploy:mcp # MCP Worker
84
+ npm run deploy # public query Worker
85
+ npm run deploy:mcp # MCP Worker
86
+ npm run deploy:rockenhaus # rockenhaus query Worker (uses committed wrangler file)
78
87
  wrangler secret put MCP_TOKEN -c wrangler.mcp.toml
79
88
  wrangler secret put TURNSTILE_SECRET # optional; skips verification when unset
80
89
  ```
@@ -101,13 +110,114 @@ wrangler secret put TURNSTILE_SECRET # optional; skips verification when unset
101
110
  export R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... CLOUDFLARE_ACCOUNT_ID=...
102
111
  export CORPUS_GIT_ORG=your-org GITHUB_TOKEN=... # for sync-runner clone auth
103
112
 
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
113
+ npm run sync:dry # plan upload for the default `corpus` target
114
+ npm run sync # upload + prune
115
+ npm run sync:public # skyphusion public target (when targets.json has it)
116
+ npm run sync:internal # skyphusion internal target
117
+ npm run sync:rockenhaus # rockenhaus target
118
+ npm run sync:run # isolated clone root, sync all targets, optional reindex
107
119
  ```
108
120
 
121
+ Useful npm scripts (also `npx` CLIs for the first two after install):
122
+
123
+ | Script | Role |
124
+ | --- | --- |
125
+ | `search-mcp-sync` / `npm run sync` | One-target R2 sync |
126
+ | `search-mcp-sync-run` / `npm run sync:run` | Clone/fetch + multi-target + reindex |
127
+ | `npm run guard:targets` | Additive-only targets.json check |
128
+ | `npm run escrow` | Age-escrow + restore proof for targets secret |
129
+ | `npm run materialize-config` | CI: write wrangler + targets from env secrets |
130
+
109
131
  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
132
 
133
+ ### Bounding a corpus: `includePaths` vs `excludePaths`
134
+
135
+ `excludePaths` is a denylist and is **fail-open**: add a new top-level file to a
136
+ repo and it silently joins the corpus. That is fine for a docs site. It is wrong
137
+ whenever the corpus boundary actually matters, because "we forgot to exclude it"
138
+ becomes a real incident.
139
+
140
+ `includePaths` / `excludePaths` may sit at the **top level** (same rule for every
141
+ target that lists the repo) or **nested under a target** (search-mcp#62). When
142
+ both set the same repo, the per-target map wins for that target only -- so a
143
+ repo can be full-tree on internal and `_corpus/`-only on a public court surface.
144
+
145
+ `includePaths` is an allowlist and is **fail-closed**: when a repo has an entry,
146
+ only paths under those prefixes are eligible and everything else is refused.
147
+
148
+ ```json
149
+ {
150
+ "includePaths": { "my-repo": ["docs/", "_corpus/"] },
151
+ "excludePaths": { "my-repo": ["docs/internal/"] }
152
+ }
153
+ ```
154
+
155
+ The two compose subtractively: `includePaths` decides what is eligible, then
156
+ `excludePaths` subtracts from that. A denylist entry can never add a path back.
157
+
158
+ Omit a repo from `includePaths` to keep the previous behaviour. An entry that is
159
+ present but **empty** (`[]`) is a configuration error, not a way to say "index
160
+ nothing": those are different states and collapsing them into the permissive one
161
+ is how a whole repo joins a corpus by accident.
162
+
163
+ #### A present allowlist must earn its keep
164
+
165
+ An allowlist that quietly matches nothing is worse than no allowlist. The sync
166
+ plans zero objects, the mirror prune deletes the corpus that was there, the
167
+ reindex succeeds over nothing, and the answer surface returns a confident nothing
168
+ with every status light green. So each of these refuses the run (exit 2) before
169
+ anything uploads or is pruned:
170
+
171
+ | Refusal | Meaning |
172
+ | --- | --- |
173
+ | `include_paths_no_match` | An entry matched zero git-tracked files (a typo, or a directory that moved) |
174
+ | `include_paths_entry_empty` | The entry is `[]` |
175
+ | `include_paths_entry_invalid` | An entry cannot match a git path (absolute, `..`, backslash, non-string) |
176
+ | `include_paths_entry_not_array` / `include_paths_not_object` | Wrong config shape |
177
+ | `include_paths_unknown_repo` | The entry names a repo no target lists, so nothing reads it |
178
+ | `include_paths_repo_not_cloned` | An allowlisted repo is missing from the clone root |
179
+ | `include_paths_all_excluded` | `excludePaths` removed everything the allowlist selected |
180
+ | `include_paths_all_filtered` | Nothing the allowlist selected survived the ingest filters |
181
+
182
+ Shape and repo-name checks run for **every** target on every sync, not just the
183
+ one being synced, so a rule that has rotted is caught by the next run of any
184
+ target. A stale `excludePaths` repo name warns rather than refuses: denylist rot
185
+ grows a corpus, allowlist rot empties one.
186
+
187
+ Verify what a target will actually upload before you trust it:
188
+
189
+ ```sh
190
+ node scripts/sync.mjs my-target --dry-run
191
+ ```
192
+
193
+ ### Size cap
194
+
195
+ Objects over `SYNC_MAX_BYTES` (default 4 MB) are skipped. Skips are summarised at
196
+ the end of the run, not only warned inline, because a file silently missing from
197
+ the corpus looks exactly like a file the corpus does not contain -- the worst
198
+ failure mode for something that answers questions. Pass `--fail-on-skip` to turn
199
+ an incomplete corpus into a failed run.
200
+
201
+ ```sh
202
+ SYNC_MAX_BYTES=$((16 * 1024 * 1024)) node scripts/sync.mjs corpus --fail-on-skip
203
+ ```
204
+
205
+ ### Corpus manifest (optional)
206
+
207
+ A corpus producer can publish a `manifest.json` next to its objects so the widget
208
+ renders citations instead of raw R2 keys. Entries need a `key`; `title`, `url`,
209
+ `page`, and `total_pages` are used when present:
210
+
211
+ ```json
212
+ { "pages": [
213
+ { "key": "my-doc/p003.txt", "title": "Deploy guide", "url": "/docs/deploy/", "page": 3, "total_pages": 12 }
214
+ ] }
215
+ ```
216
+
217
+ Keys are matched exactly first, then by **suffix**, because the sync namespaces
218
+ every object under its repo name (`<repo>/<path>`) while a producer naturally
219
+ writes its manifest in terms of its own paths.
220
+
111
221
  ### Reindex dispatch
112
222
 
113
223
  AI Search rejects a new reindex job for two distinct reasons, and `sync-runner` clears both
@@ -118,13 +228,16 @@ before dispatching:
118
228
  2. **The post-job cooldown.** Even once a job ends, a new one is refused for a cooldown window
119
229
  with `sync_in_cooldown [code: 7020]`. Waiting for the job to end is necessary but not
120
230
  sufficient, so we retry until it clears.
231
+ 3. **Transient connect failures.** `unable_to_connect_to_ai_search [code: 7017]` on jobs create
232
+ is retried under the same budget as cooldown (search-mcp#73). Treating it as terminal made
233
+ the whole sync red while R2 already had the new objects.
121
234
 
122
235
  Waiting (rather than skipping) means the job we start always lands strictly after our own
123
236
  upload, so it sees every object this run wrote. Merge bursts still coalesce: a waiting run holds
124
237
  the workflow concurrency group, and GitHub keeps only the newest queued run, so the runs behind
125
238
  it collapse instead of each firing their own reindex.
126
239
 
127
- Each wait has its own budget (10 min in-flight, 10 min cooldown) rather than one shared
240
+ Each wait has its own budget (10 min in-flight, 10 min cooldown/connect) rather than one shared
128
241
  deadline, since the two are additive on a perfectly healthy path: a run can wait minutes for an
129
242
  in-flight reindex and then still owe a cooldown wait.
130
243
 
@@ -144,9 +257,30 @@ Copy `public/ask-widget.js` and `public/ask-widget.css` to your docs site:
144
257
  data-endpoint="https://search.example.com/ask"
145
258
  data-target="#docs-ask"
146
259
  data-label="Ask the docs"
260
+ data-manifest="/corpus-manifest.json"
261
+ data-empty-text="Nothing in the indexed corpus addresses that."
147
262
  data-sitekey="YOUR_TURNSTILE_SITEKEY"></script>
148
263
  ```
149
264
 
265
+ `data-manifest` is optional; without it sources render as raw object keys. A
266
+ missing or malformed manifest degrades to raw keys rather than breaking answers.
267
+
268
+ `data-empty-text` is shown when a query returns no retrieved sources, so an
269
+ unsourced answer is never left on screen looking authoritative.
270
+
271
+ ### Per-site system prompts
272
+
273
+ One deployment can serve several sites. `ORIGIN_PROFILES` is a JSON object in
274
+ `[vars]` mapping an exact request `Origin` to that site's system prompt:
275
+
276
+ ```toml
277
+ ORIGIN_PROFILES = '{"https://docs.example.com":"You are the docs assistant...","https://other.example":"You are..."}'
278
+ ```
279
+
280
+ Precedence is `ORIGIN_PROFILES`, then the legacy blog special-case, then
281
+ `ASSISTANT_SYSTEM_PROMPT`. A malformed value is logged and ignored so a bad var
282
+ cannot take `/ask` down.
283
+
150
284
  ## Who this is for
151
285
 
152
286
  Operators building documentation search, agent tooling, or internal knowledge bases on [Cloudflare AI Search](https://developers.cloudflare.com/ai-search/) with MCP and a browser widget.
@@ -154,8 +288,9 @@ Operators building documentation search, agent tooling, or internal knowledge ba
154
288
  ## Links
155
289
 
156
290
  - **Deploy guide:** [docs/DEPLOY.md](docs/DEPLOY.md)
157
- - **Skyphusion Labs:** https://skyphusion.org · **Org:** https://github.com/skyphusion-labs
158
- - **Related:** [vivijure](https://github.com/skyphusion-labs/vivijure), [postern](https://github.com/skyphusion-labs/postern)
291
+ - **Live ask UIs:** [skyphusion.net/search](https://skyphusion.net/search/) · [vivijure.com](https://vivijure.com) (ask widget)
292
+ - **Skyphusion Labs:** https://skyphusion.org · **Blog:** https://skyphusion.net · **Org:** https://github.com/skyphusion-labs
293
+ - **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)
159
294
 
160
295
  ## License
161
296
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyphusion/search-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.4.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",
@@ -60,13 +60,18 @@
60
60
  "sync:internal": "node scripts/sync.mjs internal",
61
61
  "sync:dry": "node scripts/sync.mjs corpus --dry-run",
62
62
  "sync:run": "node scripts/sync-runner.mjs",
63
- "materialize-config": "node scripts/materialize-config.mjs"
63
+ "materialize-config": "node scripts/materialize-config.mjs",
64
+ "escrow": "node scripts/escrow-targets.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"
64
68
  },
65
69
  "dependencies": {
66
70
  "@aws-sdk/client-s3": "^3.888.0"
67
71
  },
68
72
  "devDependencies": {
69
73
  "@cloudflare/workers-types": "^5.20260713.1",
74
+ "@types/node": "^22.15.0",
70
75
  "@vitest/coverage-istanbul": "^4.1.10",
71
76
  "typescript": "^7.0.2",
72
77
  "vitest": "^4.1.10",
@@ -16,6 +16,12 @@
16
16
  var label = script.getAttribute("data-label") || "Ask";
17
17
  var placeholder = script.getAttribute("data-placeholder") || "Ask a question about the docs";
18
18
  var sitekey = script.getAttribute("data-sitekey") || "";
19
+ // Optional corpus manifest: maps an R2 object key to a human title, a URL,
20
+ // and a page number, so sources render as citations instead of raw keys.
21
+ var manifestUrl = script.getAttribute("data-manifest") || "";
22
+ var emptyText =
23
+ script.getAttribute("data-empty-text") ||
24
+ "Nothing in the indexed corpus addresses that.";
19
25
 
20
26
  function ready(fn) {
21
27
  if (document.readyState !== "loading") fn();
@@ -69,6 +75,97 @@
69
75
  }
70
76
  }
71
77
 
78
+
79
+ // ---- corpus manifest -------------------------------------------------
80
+ //
81
+ // Without a manifest the widget can only show the R2 object key, which is
82
+ // an implementation detail ("repo/docs/DEPLOY.md"). With one it can show
83
+ // what a reader actually needs: the document title, a link, and a page.
84
+ //
85
+ // Entries are matched by exact key first, then by SUFFIX. Suffix matching
86
+ // matters because the sync prefixes every object with its repo name, so the
87
+ // key in R2 is "<repo>/<path>" while a corpus producer naturally writes its
88
+ // manifest in terms of its own paths. Suffix matching lets the producer stay
89
+ // ignorant of how the sync namespaces things.
90
+ var manifest = null;
91
+ var manifestIndex = null;
92
+
93
+ function indexManifest(data) {
94
+ var entries = [];
95
+ if (Array.isArray(data)) entries = data;
96
+ else if (data && Array.isArray(data.pages)) entries = data.pages;
97
+ else if (data && Array.isArray(data.objects)) entries = data.objects;
98
+ var byKey = {};
99
+ entries.forEach(function (e) {
100
+ if (e && typeof e.key === "string") byKey[e.key] = e;
101
+ });
102
+ return { byKey: byKey, entries: entries };
103
+ }
104
+
105
+ function loadManifest() {
106
+ if (!manifestUrl) return Promise.resolve(null);
107
+ if (manifest) return Promise.resolve(manifest);
108
+ return fetch(manifestUrl)
109
+ .then(function (r) {
110
+ return r.ok ? r.json() : null;
111
+ })
112
+ .then(function (data) {
113
+ if (!data) return null;
114
+ manifest = data;
115
+ manifestIndex = indexManifest(data);
116
+ return data;
117
+ })
118
+ .catch(function () {
119
+ // A missing or broken manifest must not break answers. Degrade to
120
+ // showing raw keys rather than failing the request.
121
+ return null;
122
+ });
123
+ }
124
+
125
+ function lookup(key) {
126
+ if (!manifestIndex) return null;
127
+ if (manifestIndex.byKey[key]) return manifestIndex.byKey[key];
128
+ for (var i = 0; i < manifestIndex.entries.length; i++) {
129
+ var e = manifestIndex.entries[i];
130
+ if (e && typeof e.key === "string" && key.indexOf(e.key) >= 0) {
131
+ var at = key.length - e.key.length;
132
+ if (at >= 0 && key.slice(at) === e.key) return e;
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function citationNode(key) {
139
+ var entry = lookup(key);
140
+ var li = document.createElement("li");
141
+ if (!entry) {
142
+ li.textContent = key;
143
+ return li;
144
+ }
145
+ var label = entry.title || key;
146
+ if (entry.url) {
147
+ var a = document.createElement("a");
148
+ a.setAttribute("href", entry.url);
149
+ a.textContent = label;
150
+ li.appendChild(a);
151
+ } else {
152
+ li.appendChild(document.createTextNode(label));
153
+ }
154
+ var bits = [];
155
+ if (entry.page) {
156
+ bits.push("page " + entry.page + (entry.total_pages ? " of " + entry.total_pages : ""));
157
+ }
158
+ if (entry.case_number) bits.push("case " + entry.case_number);
159
+ if (entry.filed_date) bits.push("filed " + entry.filed_date);
160
+ if (bits.length) {
161
+ var span = document.createElement("span");
162
+ span.className = "vjask-source-meta";
163
+ span.textContent = " (" + bits.join(", ") + ")";
164
+ li.appendChild(span);
165
+ }
166
+ return li;
167
+ }
168
+
72
169
  function renderSources(chunks) {
73
170
  var seen = {};
74
171
  var items = [];
@@ -80,12 +177,18 @@
80
177
  }
81
178
  });
82
179
  if (!items.length) return;
83
- sources.innerHTML = "<li class='vjask-sources-title'>Sources</li>" +
84
- items.map(function (k) {
85
- return "<li>" + k.replace(/[&<>]/g, function (ch) {
86
- return { "&": "&amp;", "<": "&lt;", ">": "&gt;" }[ch];
87
- }) + "</li>";
88
- }).join("");
180
+ sawSources = true;
181
+ // Built via the DOM API rather than innerHTML: entries come from the
182
+ // corpus manifest, and concatenating them into markup would be an
183
+ // injection path through a data file.
184
+ sources.textContent = "";
185
+ var title = document.createElement("li");
186
+ title.className = "vjask-sources-title";
187
+ title.textContent = "Sources";
188
+ sources.appendChild(title);
189
+ items.forEach(function (k) {
190
+ sources.appendChild(citationNode(k));
191
+ });
89
192
  sources.hidden = false;
90
193
  }
91
194
 
@@ -109,6 +212,8 @@
109
212
  if (delta && delta.content) answer.textContent += delta.content;
110
213
  }
111
214
 
215
+ var sawSources = false;
216
+
112
217
  form.addEventListener("submit", function (ev) {
113
218
  ev.preventDefault();
114
219
  var q = input.value.trim();
@@ -116,8 +221,10 @@
116
221
  btn.disabled = true;
117
222
  answer.textContent = "";
118
223
  sources.hidden = true;
119
- sources.innerHTML = "";
224
+ sources.textContent = "";
225
+ sawSources = false;
120
226
 
227
+ loadManifest().then(function () {
121
228
  fetch(endpoint, {
122
229
  method: "POST",
123
230
  headers: { "content-type": "application/json" },
@@ -153,6 +260,12 @@
153
260
  answer.textContent = "Sorry, something went wrong (" + err.message + ").";
154
261
  })
155
262
  .finally(function () {
263
+ // No retrieved sources means nothing in the corpus matched. Say that
264
+ // outright rather than leaving a plausible-looking unsourced answer
265
+ // on screen.
266
+ if (!sawSources && !answer.textContent.trim()) {
267
+ answer.textContent = emptyText;
268
+ }
156
269
  btn.disabled = false;
157
270
  if (sitekey && window.turnstile) {
158
271
  try {
@@ -160,6 +273,7 @@
160
273
  } catch (e) {}
161
274
  }
162
275
  });
276
+ });
163
277
  });
164
278
  });
165
279
  })();
@@ -123,19 +123,323 @@ export function ingestKind(relPath) {
123
123
  return DOC_EXT.has(ext) || basename(relPath).startsWith("README") ? "doc" : "code";
124
124
  }
125
125
 
126
+
127
+ /**
128
+ * One prefix rule, shared by the allowlist and the denylist so the two can
129
+ * never drift. An entry is a repo-relative path: with a trailing "/" it means
130
+ * the subtree, without one it means that exact file or the subtree rooted at
131
+ * that name (so "docs" matches "docs" and "docs/a.md" but never "docsite/a.md").
132
+ */
133
+ export function matchesPathPrefix(relPath, prefix) {
134
+ if (typeof prefix !== "string" || prefix === "") return false;
135
+ if (prefix.endsWith("/")) return relPath.startsWith(prefix);
136
+ return relPath === prefix || relPath.startsWith(prefix + "/");
137
+ }
138
+
126
139
  /**
127
- * Per-repo corpus exclusion (targets.json `excludePaths`). An entry is a repo-relative
128
- * path: with a trailing "/" it excludes the whole subtree; without, it excludes that
129
- * exact file or the subtree rooted at that name.
140
+ * Per-repo corpus exclusion (targets.json `excludePaths`). Denylist, applied on
141
+ * top of the allowlist. Absent or empty means "exclude nothing".
130
142
  */
131
143
  export function isExcludedPath(relPath, prefixes) {
132
144
  for (const p of prefixes || []) {
133
- if (!p) continue;
134
- if (p.endsWith("/")) {
135
- if (relPath.startsWith(p)) return true;
136
- } else if (relPath === p || relPath.startsWith(p + "/")) {
137
- return true;
138
- }
145
+ if (matchesPathPrefix(relPath, p)) return true;
139
146
  }
140
147
  return false;
141
148
  }
149
+
150
+ /**
151
+ * Per-repo corpus ALLOWLIST (targets.json `includePaths`). When a repo has an
152
+ * entry, ONLY paths under one of its prefixes are eligible for the corpus;
153
+ * everything else is refused. When a repo has NO entry (undefined/null) every
154
+ * path is eligible and `excludePaths` alone applies, so a config that never
155
+ * mentions includePaths behaves exactly as it did before.
156
+ *
157
+ * Why both exist. A denylist is fail-OPEN: add a new top-level file to a repo
158
+ * and it silently joins the corpus. That is fine for a docs site and wrong for
159
+ * a corpus whose boundary matters -- a court-record mirror, anything with a
160
+ * privileged directory, anything where "we forgot to exclude it" is an incident
161
+ * rather than noise. An allowlist is fail-CLOSED: a new path is ineligible
162
+ * until someone says otherwise.
163
+ *
164
+ * An entry that is PRESENT but empty (or malformed) matches nothing rather than
165
+ * everything. "The operator wrote an allowlist" and "the operator wrote no
166
+ * allowlist" are different states and must not collapse into the permissive
167
+ * one; the config validation below turns that state into a hard error before a
168
+ * sync ever reaches this matcher.
169
+ */
170
+ export function isIncludedPath(relPath, prefixes) {
171
+ if (prefixes === undefined || prefixes === null) return true;
172
+ if (!Array.isArray(prefixes)) return false;
173
+ for (const p of prefixes) {
174
+ if (matchesPathPrefix(relPath, p)) return true;
175
+ }
176
+ return false;
177
+ }
178
+
179
+ /**
180
+ * Thrown for every includePaths refusal. `code` names the specific state so a
181
+ * test can prove a failure happened for the stated reason and not by accident.
182
+ */
183
+ export class IncludePathsError extends Error {
184
+ constructor(code, message) {
185
+ super(message);
186
+ this.name = "IncludePathsError";
187
+ this.code = code;
188
+ }
189
+ }
190
+
191
+ function describeType(v) {
192
+ if (v === null) return "null";
193
+ if (Array.isArray(v)) return "array";
194
+ return typeof v;
195
+ }
196
+
197
+ function q(v) {
198
+ return typeof v === "string" ? JSON.stringify(v) : describeType(v) + " " + JSON.stringify(v);
199
+ }
200
+
201
+ /** Every repo named by any target. An includePaths key outside this set reaches nothing. */
202
+ export function knownTargetRepos(cfg) {
203
+ const repos = new Set();
204
+ for (const target of Object.values(cfg?.targets || {})) {
205
+ for (const repo of target?.repos || []) repos.add(repo);
206
+ }
207
+ return repos;
208
+ }
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
+
279
+ /** Shape check for one repo entry. Returns {code, message} objects, never throws. */
280
+ export function validateIncludePathsEntry(repo, prefixes) {
281
+ const errors = [];
282
+ if (!Array.isArray(prefixes)) {
283
+ errors.push({
284
+ code: "include_paths_entry_not_array",
285
+ message:
286
+ `includePaths for repo ${q(repo)} must be an array of repo-relative path prefixes, ` +
287
+ `got ${describeType(prefixes)}. Shape: "includePaths": { "my-repo": ["_corpus/"] }.`,
288
+ });
289
+ return errors;
290
+ }
291
+ if (prefixes.length === 0) {
292
+ errors.push({
293
+ code: "include_paths_entry_empty",
294
+ message:
295
+ `includePaths for repo ${q(repo)} is an empty array, which is a configuration error, ` +
296
+ `not an instruction to index nothing. Remove the repo entry to index all of it, or ` +
297
+ `list the prefixes to index.`,
298
+ });
299
+ return errors;
300
+ }
301
+ for (const p of prefixes) {
302
+ if (typeof p !== "string" || p.trim() === "") {
303
+ errors.push({
304
+ code: "include_paths_entry_invalid",
305
+ message:
306
+ `includePaths for repo ${q(repo)} has entry ${q(p)}: entries must be non-empty strings.`,
307
+ });
308
+ } else if (p.startsWith("/")) {
309
+ errors.push({
310
+ code: "include_paths_entry_invalid",
311
+ message:
312
+ `includePaths for repo ${q(repo)} has entry ${q(p)}: entries are repo-relative and ` +
313
+ `must not start with "/". A git path never does, so this would match nothing.`,
314
+ });
315
+ } else if (p.split("/").includes("..")) {
316
+ errors.push({
317
+ code: "include_paths_entry_invalid",
318
+ message:
319
+ `includePaths for repo ${q(repo)} has entry ${q(p)}: ".." is not something a git path ` +
320
+ `can start with, so this would match nothing.`,
321
+ });
322
+ } else if (p.includes("\\")) {
323
+ errors.push({
324
+ code: "include_paths_entry_invalid",
325
+ message:
326
+ `includePaths for repo ${q(repo)} has entry ${q(p)}: git paths use "/" as the ` +
327
+ `separator, so a backslash would match nothing.`,
328
+ });
329
+ }
330
+ }
331
+ return errors;
332
+ }
333
+
334
+ /**
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.
338
+ *
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.
342
+ */
343
+ export function validateIncludePathsConfig(cfg) {
344
+ const errors = [];
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
+ );
353
+ }
354
+ return errors;
355
+ }
356
+
357
+ /** Fail-closed wrapper: throws IncludePathsError carrying the first code and every message. */
358
+ export function assertIncludePathsConfig(cfg) {
359
+ const errors = validateIncludePathsConfig(cfg);
360
+ if (!errors.length) return;
361
+ throw new IncludePathsError(errors[0].code, errors.map((e) => e.message).join(" "));
362
+ }
363
+
364
+ /**
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.
369
+ */
370
+ export function unknownExcludePathsRepos(cfg) {
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];
389
+ }
390
+
391
+ /**
392
+ * Pure path selection for one repo: allowlist first, then denylist on top, so
393
+ * the two compose as defense in depth rather than one replacing the other.
394
+ *
395
+ * `relPaths` is the repo git-tracked file list. Refuses, loudly:
396
+ *
397
+ * include_paths_entry_* the entry is malformed or empty
398
+ * include_paths_no_match an entry matched ZERO tracked files
399
+ * include_paths_all_excluded the allowlist matched, excludePaths took it all
400
+ *
401
+ * The zero-match refusal is the point of the whole mechanism. A prefix that
402
+ * matches nothing (a rename, a misspelling, a directory that moved) otherwise
403
+ * plans an empty corpus, the mirror prune deletes what was there, the reindex
404
+ * succeeds over nothing, and the answer surface returns a confident nothing with
405
+ * every status light green. Coverage is measured against the tracked list before
406
+ * exclusion, so "this prefix names nothing in the repo" stays a distinct
407
+ * diagnostic from "everything it named was excluded".
408
+ */
409
+ export function selectRepoPaths(repo, relPaths, opts = {}) {
410
+ const { includePrefixes, excludePrefixes } = opts;
411
+ const paths = relPaths || [];
412
+ if (includePrefixes === undefined || includePrefixes === null) {
413
+ return paths.filter((rel) => !isExcludedPath(rel, excludePrefixes));
414
+ }
415
+
416
+ const shapeErrors = validateIncludePathsEntry(repo, includePrefixes);
417
+ if (shapeErrors.length) {
418
+ throw new IncludePathsError(shapeErrors[0].code, shapeErrors.map((e) => e.message).join(" "));
419
+ }
420
+
421
+ const unmatched = includePrefixes.filter((p) => !paths.some((rel) => matchesPathPrefix(rel, p)));
422
+ if (unmatched.length) {
423
+ throw new IncludePathsError(
424
+ "include_paths_no_match",
425
+ `includePaths for repo ${q(repo)} matched 0 git-tracked files: ` +
426
+ `${unmatched.map(q).join(", ")} (the repo has ${paths.length} tracked file(s)). ` +
427
+ `An allowlist entry that matches nothing would sync an empty corpus over a healthy one ` +
428
+ `and report success, so the sync refuses instead. Fix the prefix in targets.json ` +
429
+ `(repo-relative, trailing slash for a subtree), or drop the entry if the path is gone. ` +
430
+ `Only the prefixes named above matched nothing.`,
431
+ );
432
+ }
433
+
434
+ const included = paths.filter((rel) => isIncludedPath(rel, includePrefixes));
435
+ const kept = included.filter((rel) => !isExcludedPath(rel, excludePrefixes));
436
+ if (kept.length === 0) {
437
+ throw new IncludePathsError(
438
+ "include_paths_all_excluded",
439
+ `includePaths for repo ${q(repo)} selected ${included.length} file(s) and excludePaths ` +
440
+ `removed every one of them. That leaves nothing to sync for this repo, which is an ` +
441
+ `empty corpus dressed as a successful run. Widen includePaths or narrow excludePaths.`,
442
+ );
443
+ }
444
+ return kept;
445
+ }
@@ -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
@@ -36,7 +36,11 @@ import {
36
36
  ingestContentType,
37
37
  ingestKind,
38
38
  fileExt,
39
- isExcludedPath,
39
+ selectRepoPaths,
40
+ assertIncludePathsConfig,
41
+ unknownExcludePathsRepos,
42
+ pathMapsForTarget,
43
+ IncludePathsError,
40
44
  } from "./sync-ingest.mjs";
41
45
  import {
42
46
  resolveTargetsPath,
@@ -47,11 +51,18 @@ import {
47
51
  const HERE = dirname(fileURLToPath(import.meta.url));
48
52
  const TARGETS_PATH = resolveTargetsPath(HERE);
49
53
  const REPO_ROOT = defaultRepoRoot(HERE, TARGETS_PATH);
50
- const MAX_BYTES = 4 * 1024 * 1024;
54
+ // Objects larger than this are skipped. Configurable because the right value
55
+ // depends on the corpus: 4 MB is fine for a source tree and wrong for scanned
56
+ // PDFs, where the single largest document is often the one that matters most.
57
+ // Skips are also summarised at the end of the run, not only warned inline: a
58
+ // warning in a long CI log is a silent omission in practice.
59
+ const MAX_BYTES = Number(process.env.SYNC_MAX_BYTES || 4 * 1024 * 1024);
51
60
 
52
61
  const args = process.argv.slice(2);
53
62
  const DRY = args.includes("--dry-run");
54
63
  const SKIP_GITHUB = args.includes("--no-github-verify") || process.env.SYNC_SKIP_GITHUB_VERIFY === "1";
64
+ // Opt-in strictness: turn an incomplete corpus into a failed run.
65
+ const FAIL_ON_SKIP = args.includes("--fail-on-skip");
55
66
  const targetName = args.find((a) => !a.startsWith("--"));
56
67
 
57
68
  const SKIP_EXT = new Set([
@@ -123,16 +134,33 @@ function loadConfig() {
123
134
  return JSON.parse(readFileSync(TARGETS_PATH, "utf8"));
124
135
  }
125
136
 
126
- function planRepo(repo, excludePrefixes = []) {
137
+ function planRepo(repo, excludePrefixes, includePrefixes, skipped = []) {
127
138
  const repoDir = join(REPO_ROOT, repo);
139
+ const hasAllowlist = includePrefixes !== undefined && includePrefixes !== null;
128
140
  if (!existsSync(repoDir)) {
141
+ // A repo with an allowlist has been described exactly: it contributes these
142
+ // paths and nothing else. A missing clone contributes nothing, which is the
143
+ // same empty-corpus-with-green-lights failure the allowlist exists to stop.
144
+ if (hasAllowlist) {
145
+ throw new IncludePathsError(
146
+ "include_paths_repo_not_cloned",
147
+ `includePaths for repo "${repo}" cannot be enforced: not cloned at ${repoDir}. ` +
148
+ `A repo with an allowlist is expected to contribute exactly those paths, so a ` +
149
+ `missing clone refuses the sync instead of quietly syncing nothing for it.`,
150
+ );
151
+ }
129
152
  console.warn(` ! skip ${repo}: not cloned at ${repoDir}`);
130
153
  return [];
131
154
  }
155
+ // Allowlist first, then denylist on top; both refusals happen here, before a
156
+ // single object is uploaded or pruned.
157
+ const candidates = selectRepoPaths(repo, trackedFiles(repoDir), {
158
+ includePrefixes,
159
+ excludePrefixes,
160
+ });
132
161
  const items = [];
133
- for (const rel of trackedFiles(repoDir)) {
162
+ for (const rel of candidates) {
134
163
  if (shouldSkip(rel)) continue;
135
- if (isExcludedPath(rel, excludePrefixes)) continue;
136
164
  const abs = join(repoDir, rel);
137
165
  let size;
138
166
  try {
@@ -141,7 +169,10 @@ function planRepo(repo, excludePrefixes = []) {
141
169
  continue;
142
170
  }
143
171
  if (size > MAX_BYTES) {
144
- console.warn(` ! skip ${repo}/${rel}: ${(size / 1048576).toFixed(1)} MB over 4 MB`);
172
+ const mb = (size / 1048576).toFixed(1);
173
+ const capMb = (MAX_BYTES / 1048576).toFixed(1);
174
+ console.warn(` ! skip ${repo}/${rel}: ${mb} MB over the ${capMb} MB cap`);
175
+ skipped.push({ repo, path: rel, mb });
145
176
  continue;
146
177
  }
147
178
  const sample = readTextSample(abs, size);
@@ -161,12 +192,23 @@ function planRepo(repo, excludePrefixes = []) {
161
192
  },
162
193
  });
163
194
  }
195
+ if (hasAllowlist && items.length === 0) {
196
+ throw new IncludePathsError(
197
+ "include_paths_all_filtered",
198
+ `includePaths for repo "${repo}" selected ${candidates.length} file(s) and none of them ` +
199
+ `survived the ingest filters (build/vendor directories, lockfiles, .env, binaries, the ` +
200
+ `size cap, or the text sniff). Nothing would upload for this repo, so the sync refuses ` +
201
+ `rather than publish an empty corpus.`,
202
+ );
203
+ }
164
204
  return items;
165
205
  }
166
206
 
167
207
  async function main() {
168
208
  if (!targetName) {
169
- console.error("usage: node scripts/sync.mjs <target> [--dry-run] [--no-github-verify]");
209
+ console.error(
210
+ "usage: node scripts/sync.mjs <target> [--dry-run] [--no-github-verify] [--fail-on-skip]",
211
+ );
170
212
  process.exit(2);
171
213
  }
172
214
  const cfg = loadConfig();
@@ -176,6 +218,17 @@ async function main() {
176
218
  process.exit(2);
177
219
  }
178
220
 
221
+ // Config-level allowlist checks run for every target, not just the one being
222
+ // synced: a rule that names a repo no target lists is rot, and rot found only
223
+ // when someone syncs that one target is rot found late.
224
+ assertIncludePathsConfig(cfg);
225
+ for (const repo of unknownExcludePathsRepos(cfg)) {
226
+ console.warn(
227
+ ` ! excludePaths names repo '${repo}', which no target lists in its repos. ` +
228
+ "That rule is inert; delete it or add the repo to a target.",
229
+ );
230
+ }
231
+
179
232
  assertPublicCorpusBoundary(cfg, targetName);
180
233
  if (targetName === "public" && !SKIP_GITHUB) {
181
234
  const gh = await assertPublicGithubVisibility(cfg);
@@ -194,14 +247,39 @@ async function main() {
194
247
  console.log(`Target '${targetName}' -> bucket ${target.bucket} (instance ${target.instance})`);
195
248
  console.log(`Repo root: ${REPO_ROOT}${DRY ? " [DRY RUN]" : ""}`);
196
249
 
250
+ // #62: per-target path maps win over top-level for this target.
251
+ const pathMaps = pathMapsForTarget(cfg, targetName);
252
+
197
253
  const plan = [];
254
+ const skipped = [];
198
255
  for (const repo of target.repos) {
199
- const items = planRepo(repo, cfg.excludePaths?.[repo]);
256
+ const items = planRepo(
257
+ repo,
258
+ pathMaps.excludePaths?.[repo],
259
+ pathMaps.includePaths?.[repo],
260
+ skipped,
261
+ );
200
262
  if (items.length) console.log(` + ${repo}: ${items.length} files`);
201
263
  plan.push(...items);
202
264
  }
203
265
  console.log(`Planned ${plan.length} objects for ${target.bucket}.`);
204
266
 
267
+ // Summarise oversize skips. A file silently missing from the corpus looks
268
+ // exactly like a file the corpus does not contain, which is the worst
269
+ // possible failure mode for something that answers questions.
270
+ if (skipped.length) {
271
+ console.warn(`\n ! ${skipped.length} file(s) skipped for exceeding the size cap:`);
272
+ for (const s of skipped) console.warn(` ${s.repo}/${s.path} (${s.mb} MB)`);
273
+ console.warn(
274
+ " These are NOT in the corpus. Raise SYNC_MAX_BYTES, extract their text into\n" +
275
+ " a smaller form, or accept that nothing can answer from them.",
276
+ );
277
+ if (FAIL_ON_SKIP) {
278
+ console.error("--fail-on-skip: refusing to continue with an incomplete corpus.");
279
+ process.exit(1);
280
+ }
281
+ }
282
+
205
283
  if (DRY) {
206
284
  for (const it of plan.slice(0, 25)) console.log(` ${it.key}`);
207
285
  if (plan.length > 25) console.log(` ... and ${plan.length - 25} more`);
@@ -283,6 +361,12 @@ async function main() {
283
361
  }
284
362
 
285
363
  main().catch((err) => {
364
+ // A config refusal is an operator error, not a crash: print the diagnostic
365
+ // without a stack, and exit 2 like the other targets.json failures.
366
+ if (err instanceof IncludePathsError) {
367
+ console.error(`\n${err.message}\n [${err.code}]`);
368
+ process.exit(2);
369
+ }
286
370
  console.error(err);
287
371
  process.exit(1);
288
372
  });
@@ -1,5 +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. 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
+ "includePaths": {
6
+ "example-repo": ["docs/"]
7
+ },
8
+ "_excludePaths_comment": "DENYLIST, optional, per repo. Applied after includePaths. Same top-level vs per-target nesting as includePaths.",
3
9
  "excludePaths": {
4
10
  "example-repo": ["vendor/private-notes/"]
5
11
  },
@@ -9,6 +15,15 @@
9
15
  "instance": "my-ai-search-instance",
10
16
  "bucket": "my-search-corpus",
11
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
+ }
12
27
  }
13
28
  }
14
29
  }