@contextstream/mcp-server 0.4.68 → 0.4.73

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
@@ -124,6 +124,18 @@ Your AI uses these automatically. You just code.
124
124
 
125
125
  ---
126
126
 
127
+ ## Global Fallback Workspace (Unmapped Folders)
128
+
129
+ ContextStream now supports a catch-all mode for random folders (for example `~` or ad-hoc dirs) that are not associated with a project/workspace yet.
130
+
131
+ - `init(...)` resolves normal folder mappings first (`.contextstream/config.json`, parent/global mappings).
132
+ - If no mapping exists, it uses a single hidden global fallback workspace (`.contextstream-global`) in workspace-only mode.
133
+ - Context/memory/session tools continue to work without hard setup errors.
134
+ - Project-bound actions (for example `project(action="ingest_local")`) return guided remediation to create/select a project instead of failing with a raw `project_id required` error.
135
+ - As soon as you enter a mapped project folder, that real workspace/project is prioritized and replaces fallback scope.
136
+
137
+ ---
138
+
127
139
  ## Manual Configuration
128
140
 
129
141
  > Skip this if you ran the setup wizard.
@@ -205,7 +217,26 @@ For the local variant, export `CONTEXTSTREAM_API_KEY` before launching OpenCode.
205
217
  <details>
206
218
  <summary><b>VS Code</b></summary>
207
219
 
208
- For GitHub Copilot in VS Code, use project-level MCP at `.vscode/mcp.json`.
220
+ For GitHub Copilot in VS Code, the easiest path is the hosted remote MCP with built-in OAuth. Marketplace installs should write this remote server definition automatically.
221
+
222
+ **Hosted remote MCP (recommended)**
223
+
224
+ ```json
225
+ {
226
+ "servers": {
227
+ "contextstream": {
228
+ "type": "http",
229
+ "url": "https://mcp.contextstream.io/mcp?default_context_mode=fast"
230
+ }
231
+ }
232
+ }
233
+ ```
234
+
235
+ On first use, VS Code should prompt you to authorize ContextStream in the browser and then complete setup without an API key in the config file.
236
+
237
+ `npx @contextstream/mcp-server@latest setup` now defaults VS Code/Copilot to this hosted remote when you are using the production ContextStream cloud. To force a local runtime instead, run setup with `CONTEXTSTREAM_VSCODE_MCP_MODE=local`.
238
+
239
+ For self-hosted or non-default API deployments, local runtime remains the default:
209
240
 
210
241
  **Rust MCP (recommended)**
211
242
 
@@ -218,7 +249,12 @@ For GitHub Copilot in VS Code, use project-level MCP at `.vscode/mcp.json`.
218
249
  "args": [],
219
250
  "env": {
220
251
  "CONTEXTSTREAM_API_URL": "https://api.contextstream.io",
221
- "CONTEXTSTREAM_API_KEY": "your_key"
252
+ "CONTEXTSTREAM_API_KEY": "your_key",
253
+ "CONTEXTSTREAM_TOOLSET": "complete",
254
+ "CONTEXTSTREAM_TRANSCRIPTS_ENABLED": "true",
255
+ "CONTEXTSTREAM_HOOK_TRANSCRIPTS_ENABLED": "true",
256
+ "CONTEXTSTREAM_SEARCH_LIMIT": "15",
257
+ "CONTEXTSTREAM_SEARCH_MAX_CHARS": "2400"
222
258
  }
223
259
  }
224
260
  }
@@ -236,7 +272,12 @@ For GitHub Copilot in VS Code, use project-level MCP at `.vscode/mcp.json`.
236
272
  "args": ["--prefer-online", "-y", "@contextstream/mcp-server@latest"],
237
273
  "env": {
238
274
  "CONTEXTSTREAM_API_URL": "https://api.contextstream.io",
239
- "CONTEXTSTREAM_API_KEY": "your_key"
275
+ "CONTEXTSTREAM_API_KEY": "your_key",
276
+ "CONTEXTSTREAM_TOOLSET": "complete",
277
+ "CONTEXTSTREAM_TRANSCRIPTS_ENABLED": "true",
278
+ "CONTEXTSTREAM_HOOK_TRANSCRIPTS_ENABLED": "true",
279
+ "CONTEXTSTREAM_SEARCH_LIMIT": "15",
280
+ "CONTEXTSTREAM_SEARCH_MAX_CHARS": "2400"
240
281
  }
241
282
  }
242
283
  }
@@ -266,7 +307,12 @@ Or add to `~/.copilot/mcp-config.json` (pick one runtime):
266
307
  "args": [],
267
308
  "env": {
268
309
  "CONTEXTSTREAM_API_URL": "https://api.contextstream.io",
269
- "CONTEXTSTREAM_API_KEY": "your_key"
310
+ "CONTEXTSTREAM_API_KEY": "your_key",
311
+ "CONTEXTSTREAM_TOOLSET": "complete",
312
+ "CONTEXTSTREAM_TRANSCRIPTS_ENABLED": "true",
313
+ "CONTEXTSTREAM_HOOK_TRANSCRIPTS_ENABLED": "true",
314
+ "CONTEXTSTREAM_SEARCH_LIMIT": "15",
315
+ "CONTEXTSTREAM_SEARCH_MAX_CHARS": "2400"
270
316
  }
271
317
  }
272
318
  }
@@ -283,7 +329,12 @@ Or add to `~/.copilot/mcp-config.json` (pick one runtime):
283
329
  "args": ["--prefer-online", "-y", "@contextstream/mcp-server@latest"],
284
330
  "env": {
285
331
  "CONTEXTSTREAM_API_URL": "https://api.contextstream.io",
286
- "CONTEXTSTREAM_API_KEY": "your_key"
332
+ "CONTEXTSTREAM_API_KEY": "your_key",
333
+ "CONTEXTSTREAM_TOOLSET": "complete",
334
+ "CONTEXTSTREAM_TRANSCRIPTS_ENABLED": "true",
335
+ "CONTEXTSTREAM_HOOK_TRANSCRIPTS_ENABLED": "true",
336
+ "CONTEXTSTREAM_SEARCH_LIMIT": "15",
337
+ "CONTEXTSTREAM_SEARCH_MAX_CHARS": "2400"
287
338
  }
288
339
  }
289
340
  }
@@ -303,6 +354,8 @@ For more information, see the [GitHub Copilot CLI documentation](https://docs.gi
303
354
  - `.vscode/mcp.json`
304
355
  - Rust install: use `contextstream-mcp` as the command.
305
356
  - Node install: use `npx --prefer-online -y @contextstream/mcp-server@latest` as the command.
357
+ - Force local VS Code/Copilot setup with `CONTEXTSTREAM_VSCODE_MCP_MODE=local`.
358
+ - Force hosted remote VS Code/Copilot setup with `CONTEXTSTREAM_VSCODE_MCP_MODE=remote`.
306
359
  - Use `mcpServers` in Copilot CLI config and `servers` in VS Code config.
307
360
 
308
361
  ## Quick Troubleshooting
@@ -312,9 +365,36 @@ For more information, see the [GitHub Copilot CLI documentation](https://docs.gi
312
365
  - Remove stale version pins like `@contextstream/mcp-server@0.3.xx`.
313
366
  - Restart VS Code/Copilot after config changes.
314
367
 
368
+ ## Known Limitations
369
+
370
+ ### HTTP transport OAuth and vscode.dev dependency
371
+
372
+ The hosted HTTP MCP transport (`https://mcp.contextstream.io/mcp`) uses OAuth authentication that routes through `vscode.dev` for the redirect flow. This can fail in environments where `vscode.dev` is blocked (corporate networks, regional restrictions, CDN-level blocks).
373
+
374
+ **Workaround:** Use the stdio transport (Rust binary or Node.js) with API key authentication instead:
375
+
376
+ ```json
377
+ {
378
+ "contextstream": {
379
+ "type": "stdio",
380
+ "command": "npx",
381
+ "args": ["-y", "@contextstream/mcp-server@latest"],
382
+ "env": {
383
+ "CONTEXTSTREAM_API_KEY": "your-api-key"
384
+ }
385
+ }
386
+ }
387
+ ```
388
+
389
+ ### SDK version compatibility
390
+
391
+ `@modelcontextprotocol/sdk` versions 1.28.0 and above introduce breaking changes. The `package.json` pins the SDK to `>=1.25.1 <1.28.0` to prevent incompatible resolutions. If you experience Zod schema errors on startup, ensure your SDK version is below 1.28.0.
392
+
315
393
  ## Marketplace Note
316
394
 
317
- Marketplace npm installs can pin Node MCP versions and do not run external bootstrap scripts (`curl ... | bash` / `irm ... | iex`). Use the Rust install command directly when you want the Rust runtime.
395
+ The MCP marketplace entry now targets the hosted remote MCP at `https://mcp.contextstream.io/mcp?default_context_mode=fast` so VS Code can use the native OAuth flow instead of writing a local npm-based stdio config.
396
+
397
+ Use the Rust or Node local runtime configs above only when you explicitly want local execution, custom/self-hosted endpoints, or editor environments that do not support the hosted remote flow.
318
398
 
319
399
  ---
320
400
 
@@ -705,6 +705,7 @@ async function readFilesFromDirectory(rootPath, options = {}) {
705
705
  } catch {
706
706
  return;
707
707
  }
708
+ entries.sort((a, b) => a.name.localeCompare(b.name));
708
709
  for (const entry of entries) {
709
710
  if (files.length >= maxFiles) break;
710
711
  const fullPath = path2.join(dir, entry.name);
@@ -764,6 +765,7 @@ async function* readAllFilesInBatches(rootPath, options = {}) {
764
765
  } catch {
765
766
  return;
766
767
  }
768
+ entries.sort((a, b) => a.name.localeCompare(b.name));
767
769
  for (const entry of entries) {
768
770
  const fullPath = path2.join(dir, entry.name);
769
771
  const relPath = path2.join(relativePath, entry.name);
@@ -850,6 +852,7 @@ async function* readChangedFilesInBatches(rootPath, sinceTimestamp, options = {}
850
852
  } catch {
851
853
  return;
852
854
  }
855
+ entries.sort((a, b) => a.name.localeCompare(b.name));
853
856
  for (const entry of entries) {
854
857
  const fullPath = path2.join(dir, entry.name);
855
858
  const relPath = path2.join(relativePath, entry.name);
@@ -940,6 +943,7 @@ async function countIndexableFiles(rootPath, options = {}) {
940
943
  } catch {
941
944
  return;
942
945
  }
946
+ entries.sort((a, b) => a.name.localeCompare(b.name));
943
947
  for (const entry of entries) {
944
948
  if (count >= maxFiles) {
945
949
  stopped = true;
@@ -1,9 +1,153 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/hooks/on-read.ts
4
+ import * as fs2 from "node:fs";
5
+ import * as path2 from "node:path";
6
+ import { homedir as homedir2 } from "node:os";
7
+
8
+ // src/hot-paths.ts
4
9
  import * as fs from "node:fs";
5
10
  import * as path from "node:path";
6
11
  import { homedir } from "node:os";
12
+ var STORE_VERSION = 1;
13
+ var STORE_DIR = path.join(homedir(), ".contextstream");
14
+ var STORE_FILE = path.join(STORE_DIR, "hot-paths.json");
15
+ var MAX_PATHS_PER_SCOPE = 400;
16
+ var HALF_LIFE_MS = 3 * 24 * 60 * 60 * 1e3;
17
+ function clamp(value, min, max) {
18
+ return Math.max(min, Math.min(max, value));
19
+ }
20
+ function normalizePathKey(input) {
21
+ return input.replace(/\\/g, "/").trim();
22
+ }
23
+ function toScopeKey(input) {
24
+ const workspace = input.workspace_id || "none";
25
+ const project = input.project_id || "none";
26
+ return `${workspace}:${project}`;
27
+ }
28
+ function signalWeight(signal) {
29
+ switch (signal) {
30
+ case "search_result":
31
+ return 1.8;
32
+ case "activity_edit":
33
+ return 1.4;
34
+ case "activity_focus":
35
+ return 1.2;
36
+ case "activity_read":
37
+ default:
38
+ return 1;
39
+ }
40
+ }
41
+ function looksBroadQuery(query) {
42
+ const q = query.trim().toLowerCase();
43
+ if (!q) return true;
44
+ if (q.length <= 2) return true;
45
+ if (/^\*+$/.test(q) || q === "all files" || q === "everything") return true;
46
+ const tokens = q.split(/\s+/).filter(Boolean);
47
+ return tokens.length > 12;
48
+ }
49
+ var HotPathStore = class {
50
+ constructor() {
51
+ this.data = { version: STORE_VERSION, scopes: {} };
52
+ this.load();
53
+ }
54
+ recordPaths(scope, paths, signal) {
55
+ if (paths.length === 0) return;
56
+ const now = Date.now();
57
+ const scopeKey = toScopeKey(scope);
58
+ const profile = this.ensureScope(scopeKey);
59
+ const weight = signalWeight(signal);
60
+ for (const raw of paths) {
61
+ const pathKey = normalizePathKey(raw);
62
+ if (!pathKey) continue;
63
+ const current = profile.paths[pathKey] || { score: 0, last_seen: now, hits: 0 };
64
+ const decayed = this.decayedScore(current.score, current.last_seen, now);
65
+ profile.paths[pathKey] = {
66
+ score: decayed + weight,
67
+ last_seen: now,
68
+ hits: current.hits + 1
69
+ };
70
+ }
71
+ profile.updated_at = now;
72
+ this.pruneScope(profile);
73
+ this.persist();
74
+ }
75
+ buildHint(input) {
76
+ const scopeKey = toScopeKey(input);
77
+ const profile = this.data.scopes[scopeKey];
78
+ if (!profile) return void 0;
79
+ const now = Date.now();
80
+ const baseEntries = Object.entries(profile.paths).map(([filePath, entry]) => ({
81
+ path: filePath,
82
+ score: this.decayedScore(entry.score, entry.last_seen, now)
83
+ })).filter((entry) => entry.score > 0.05).sort((a, b) => b.score - a.score);
84
+ const active = (input.active_paths || []).map(normalizePathKey).filter(Boolean);
85
+ const merged = /* @__PURE__ */ new Map();
86
+ for (const entry of baseEntries.slice(0, Math.max(12, input.limit || 8))) {
87
+ merged.set(entry.path, {
88
+ path: entry.path,
89
+ score: Number(entry.score.toFixed(4)),
90
+ source: "history"
91
+ });
92
+ }
93
+ for (const activePath of active) {
94
+ const existing = merged.get(activePath);
95
+ if (existing) {
96
+ existing.score = Number((existing.score + 0.9).toFixed(4));
97
+ } else {
98
+ merged.set(activePath, { path: activePath, score: 0.9, source: "active" });
99
+ }
100
+ }
101
+ const limit = clamp(input.limit ?? 8, 1, 12);
102
+ const entries = [...merged.values()].sort((a, b) => b.score - a.score).slice(0, limit);
103
+ if (entries.length === 0) return void 0;
104
+ const scoreSum = entries.reduce((sum, item) => sum + item.score, 0);
105
+ const normalized = clamp(scoreSum / (limit * 2.5), 0, 1);
106
+ const confidencePenalty = looksBroadQuery(input.query) ? 0.55 : 1;
107
+ const confidence = Number((normalized * confidencePenalty).toFixed(3));
108
+ return {
109
+ entries,
110
+ confidence,
111
+ generated_at: new Date(now).toISOString(),
112
+ profile_version: STORE_VERSION
113
+ };
114
+ }
115
+ decayedScore(score, lastSeenMs, nowMs) {
116
+ const elapsed = Math.max(0, nowMs - lastSeenMs);
117
+ const decay = Math.pow(0.5, elapsed / HALF_LIFE_MS);
118
+ return score * decay;
119
+ }
120
+ ensureScope(scopeKey) {
121
+ if (!this.data.scopes[scopeKey]) {
122
+ this.data.scopes[scopeKey] = { paths: {}, updated_at: Date.now() };
123
+ }
124
+ return this.data.scopes[scopeKey];
125
+ }
126
+ pruneScope(profile) {
127
+ const entries = Object.entries(profile.paths);
128
+ if (entries.length <= MAX_PATHS_PER_SCOPE) return;
129
+ entries.sort((a, b) => b[1].score - a[1].score).slice(MAX_PATHS_PER_SCOPE).forEach(([key]) => delete profile.paths[key]);
130
+ }
131
+ load() {
132
+ try {
133
+ if (!fs.existsSync(STORE_FILE)) return;
134
+ const parsed = JSON.parse(fs.readFileSync(STORE_FILE, "utf-8"));
135
+ if (parsed?.version !== STORE_VERSION || !parsed.scopes) return;
136
+ this.data = parsed;
137
+ } catch {
138
+ }
139
+ }
140
+ persist() {
141
+ try {
142
+ fs.mkdirSync(STORE_DIR, { recursive: true });
143
+ fs.writeFileSync(STORE_FILE, JSON.stringify(this.data));
144
+ } catch {
145
+ }
146
+ }
147
+ };
148
+ var globalHotPathStore = new HotPathStore();
149
+
150
+ // src/hooks/on-read.ts
7
151
  var ENABLED = process.env.CONTEXTSTREAM_READ_HOOK_ENABLED !== "false";
8
152
  var API_URL = process.env.CONTEXTSTREAM_API_URL || "https://api.contextstream.io";
9
153
  var API_KEY = process.env.CONTEXTSTREAM_API_KEY || "";
@@ -11,13 +155,13 @@ var WORKSPACE_ID = null;
11
155
  var recentCaptures = /* @__PURE__ */ new Set();
12
156
  var CAPTURE_WINDOW_MS = 6e4;
13
157
  function loadConfigFromMcpJson(cwd) {
14
- let searchDir = path.resolve(cwd);
158
+ let searchDir = path2.resolve(cwd);
15
159
  for (let i = 0; i < 5; i++) {
16
160
  if (!API_KEY) {
17
- const mcpPath = path.join(searchDir, ".mcp.json");
18
- if (fs.existsSync(mcpPath)) {
161
+ const mcpPath = path2.join(searchDir, ".mcp.json");
162
+ if (fs2.existsSync(mcpPath)) {
19
163
  try {
20
- const content = fs.readFileSync(mcpPath, "utf-8");
164
+ const content = fs2.readFileSync(mcpPath, "utf-8");
21
165
  const config = JSON.parse(content);
22
166
  const csEnv = config.mcpServers?.contextstream?.env;
23
167
  if (csEnv?.CONTEXTSTREAM_API_KEY) {
@@ -31,10 +175,10 @@ function loadConfigFromMcpJson(cwd) {
31
175
  }
32
176
  }
33
177
  if (!WORKSPACE_ID) {
34
- const csConfigPath = path.join(searchDir, ".contextstream", "config.json");
35
- if (fs.existsSync(csConfigPath)) {
178
+ const csConfigPath = path2.join(searchDir, ".contextstream", "config.json");
179
+ if (fs2.existsSync(csConfigPath)) {
36
180
  try {
37
- const content = fs.readFileSync(csConfigPath, "utf-8");
181
+ const content = fs2.readFileSync(csConfigPath, "utf-8");
38
182
  const csConfig = JSON.parse(content);
39
183
  if (csConfig.workspace_id) {
40
184
  WORKSPACE_ID = csConfig.workspace_id;
@@ -43,15 +187,15 @@ function loadConfigFromMcpJson(cwd) {
43
187
  }
44
188
  }
45
189
  }
46
- const parentDir = path.dirname(searchDir);
190
+ const parentDir = path2.dirname(searchDir);
47
191
  if (parentDir === searchDir) break;
48
192
  searchDir = parentDir;
49
193
  }
50
194
  if (!API_KEY) {
51
- const homeMcpPath = path.join(homedir(), ".mcp.json");
52
- if (fs.existsSync(homeMcpPath)) {
195
+ const homeMcpPath = path2.join(homedir2(), ".mcp.json");
196
+ if (fs2.existsSync(homeMcpPath)) {
53
197
  try {
54
- const content = fs.readFileSync(homeMcpPath, "utf-8");
198
+ const content = fs2.readFileSync(homeMcpPath, "utf-8");
55
199
  const config = JSON.parse(content);
56
200
  const csEnv = config.mcpServers?.contextstream?.env;
57
201
  if (csEnv?.CONTEXTSTREAM_API_KEY) {
@@ -136,11 +280,25 @@ async function runOnReadHook() {
136
280
  case "Read":
137
281
  target = input.tool_input?.file_path || "";
138
282
  resultSummary = `Read file: ${target}`;
283
+ if (target) {
284
+ globalHotPathStore.recordPaths(
285
+ { workspace_id: WORKSPACE_ID || void 0, project_id: void 0 },
286
+ [target],
287
+ "activity_read"
288
+ );
289
+ }
139
290
  break;
140
291
  case "Glob":
141
292
  target = input.tool_input?.pattern || "";
142
293
  const globFiles = input.tool_result?.files || [];
143
294
  resultSummary = `Found ${globFiles.length} files matching ${target}`;
295
+ if (globFiles.length > 0) {
296
+ globalHotPathStore.recordPaths(
297
+ { workspace_id: WORKSPACE_ID || void 0, project_id: void 0 },
298
+ globFiles.slice(0, 30),
299
+ "activity_focus"
300
+ );
301
+ }
144
302
  break;
145
303
  case "Grep":
146
304
  target = input.tool_input?.pattern || "";
@@ -67,6 +67,8 @@ function getOrCreateEntry(state, cwd) {
67
67
  require_init: false,
68
68
  last_context_at: void 0,
69
69
  last_state_change_at: void 0,
70
+ index_wait_started_at: void 0,
71
+ index_wait_until: void 0,
70
72
  updated_at: nowIso()
71
73
  };
72
74
  state.workspaces[cwd] = created;
@@ -96,6 +98,8 @@ function clearContextRequired(cwd) {
96
98
  if (!target) return;
97
99
  target.entry.require_context = false;
98
100
  target.entry.last_context_at = nowIso();
101
+ target.entry.index_wait_started_at = void 0;
102
+ target.entry.index_wait_until = void 0;
99
103
  target.entry.updated_at = nowIso();
100
104
  writeState(state);
101
105
  }
@@ -147,6 +151,44 @@ function isContextFreshAndClean(cwd, maxAgeSeconds) {
147
151
  }
148
152
  return true;
149
153
  }
154
+ function startIndexWaitWindow(cwd, waitSeconds) {
155
+ if (!cwd.trim() || waitSeconds <= 0) return;
156
+ const state = readState();
157
+ const target = getOrCreateEntry(state, cwd);
158
+ if (!target) return;
159
+ const now = Date.now();
160
+ const existingUntil = target.entry.index_wait_until ? new Date(target.entry.index_wait_until).getTime() : NaN;
161
+ if (!Number.isNaN(existingUntil) && existingUntil > now) {
162
+ target.entry.updated_at = nowIso();
163
+ writeState(state);
164
+ return;
165
+ }
166
+ target.entry.index_wait_started_at = new Date(now).toISOString();
167
+ target.entry.index_wait_until = new Date(now + waitSeconds * 1e3).toISOString();
168
+ target.entry.updated_at = nowIso();
169
+ writeState(state);
170
+ }
171
+ function clearIndexWaitWindow(cwd) {
172
+ if (!cwd.trim()) return;
173
+ const state = readState();
174
+ const target = getOrCreateEntry(state, cwd);
175
+ if (!target) return;
176
+ target.entry.index_wait_started_at = void 0;
177
+ target.entry.index_wait_until = void 0;
178
+ target.entry.updated_at = nowIso();
179
+ writeState(state);
180
+ }
181
+ function indexWaitRemainingSeconds(cwd) {
182
+ if (!cwd.trim()) return null;
183
+ const state = readState();
184
+ const target = getOrCreateEntry(state, cwd);
185
+ if (!target?.entry.index_wait_until) return null;
186
+ const until = new Date(target.entry.index_wait_until).getTime();
187
+ if (Number.isNaN(until)) return null;
188
+ const remainingMs = until - Date.now();
189
+ if (remainingMs <= 0) return null;
190
+ return Math.ceil(remainingMs / 1e3);
191
+ }
150
192
 
151
193
  // src/hooks/pre-tool-use.ts
152
194
  var ENABLED = process.env.CONTEXTSTREAM_HOOK_ENABLED !== "false";
@@ -154,6 +196,9 @@ var INDEX_STATUS_FILE = path2.join(homedir2(), ".contextstream", "indexed-projec
154
196
  var DEBUG_FILE = "/tmp/pretooluse-hook-debug.log";
155
197
  var STALE_THRESHOLD_DAYS = 7;
156
198
  var CONTEXT_FRESHNESS_SECONDS = 120;
199
+ var DEFAULT_INDEX_WAIT_SECONDS = 20;
200
+ var MIN_INDEX_WAIT_SECONDS = 15;
201
+ var MAX_INDEX_WAIT_SECONDS = 20;
157
202
  var DISCOVERY_PATTERNS = ["**/*", "**/", "src/**", "lib/**", "app/**", "components/**"];
158
203
  function isDiscoveryGlob(pattern) {
159
204
  const patternLower = pattern.toLowerCase();
@@ -179,6 +224,33 @@ function isDiscoveryGrep(filePath) {
179
224
  }
180
225
  return false;
181
226
  }
227
+ function configuredIndexWaitSeconds() {
228
+ const parsed = Number.parseInt(process.env.CONTEXTSTREAM_INDEX_WAIT_SECONDS ?? "", 10);
229
+ if (Number.isNaN(parsed)) return DEFAULT_INDEX_WAIT_SECONDS;
230
+ return Math.min(MAX_INDEX_WAIT_SECONDS, Math.max(MIN_INDEX_WAIT_SECONDS, parsed));
231
+ }
232
+ function isLocalDiscoveryToolDuringIndexWait(tool, toolInput) {
233
+ if (tool === "Glob" || tool === "Explore" || tool === "SemanticSearch" || tool === "codebase_search") {
234
+ return true;
235
+ }
236
+ if (tool === "Task") {
237
+ const subagentTypeRaw = toolInput?.subagent_type || toolInput?.subagentType || "";
238
+ return subagentTypeRaw.toLowerCase().includes("explore");
239
+ }
240
+ if (tool === "Grep" || tool === "Search" || tool === "grep_search" || tool === "code_search") {
241
+ const filePath = toolInput?.path || "";
242
+ return isDiscoveryGrep(filePath);
243
+ }
244
+ if (tool === "Read" || tool === "ReadFile" || tool === "read_file") {
245
+ const filePath = toolInput?.file_path || toolInput?.path || toolInput?.file || toolInput?.target_file || "";
246
+ return isDiscoveryGrep(filePath);
247
+ }
248
+ if (tool === "list_files" || tool === "search_files" || tool === "search_files_content" || tool === "find_files" || tool === "find_by_name") {
249
+ const pattern = toolInput?.path || toolInput?.regex || toolInput?.pattern || toolInput?.query || "";
250
+ return !pattern || isDiscoveryGlob(pattern) || isDiscoveryGrep(pattern);
251
+ }
252
+ return false;
253
+ }
182
254
  function isProjectIndexed(cwd) {
183
255
  if (!fs2.existsSync(INDEX_STATUS_FILE)) {
184
256
  return { isIndexed: false, isStale: false };
@@ -440,12 +512,22 @@ async function runPreToolUseHook() {
440
512
  blockWithMessage(editorFormat, msg);
441
513
  }
442
514
  }
443
- const { isIndexed } = isProjectIndexed(cwd);
444
- fs2.appendFileSync(DEBUG_FILE, `[PreToolUse] isIndexed=${isIndexed}
445
- `);
446
- if (!isIndexed) {
447
- fs2.appendFileSync(DEBUG_FILE, `[PreToolUse] Project not indexed, allowing
515
+ const { isIndexed, isStale } = isProjectIndexed(cwd);
516
+ fs2.appendFileSync(DEBUG_FILE, `[PreToolUse] isIndexed=${isIndexed}, isStale=${isStale}
448
517
  `);
518
+ if (isIndexed && !isStale) {
519
+ clearIndexWaitWindow(cwd);
520
+ } else {
521
+ const waitSeconds = configuredIndexWaitSeconds();
522
+ if (isLocalDiscoveryToolDuringIndexWait(tool, toolInput)) {
523
+ startIndexWaitWindow(cwd, waitSeconds);
524
+ const remaining = indexWaitRemainingSeconds(cwd);
525
+ if (remaining && remaining > 0) {
526
+ const msg = `Index refresh grace window is active (${remaining}s remaining). Keep ContextStream search-first flow: mcp__contextstream__search(mode="auto", query="..."). Do not use local discovery tools yet for stale/not-indexed projects. Retry after refresh; local fallback is allowed only after ~${waitSeconds}s if index is still unavailable.`;
527
+ blockWithMessage(editorFormat, msg);
528
+ }
529
+ allowTool(editorFormat, cwd, recordStateChange);
530
+ }
449
531
  allowTool(editorFormat, cwd, recordStateChange);
450
532
  }
451
533
  if (tool === "Glob") {