@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 +86 -6
- package/dist/hooks/auto-rules.js +4 -0
- package/dist/hooks/on-read.js +169 -11
- package/dist/hooks/pre-tool-use.js +87 -5
- package/dist/hooks/runner.js +110 -31
- package/dist/hooks/session-init.js +14 -13
- package/dist/hooks/user-prompt-submit.js +12 -12
- package/dist/index.js +2334 -737
- package/package.json +3 -3
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,
|
|
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
|
-
|
|
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
|
|
package/dist/hooks/auto-rules.js
CHANGED
|
@@ -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;
|
package/dist/hooks/on-read.js
CHANGED
|
@@ -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 =
|
|
158
|
+
let searchDir = path2.resolve(cwd);
|
|
15
159
|
for (let i = 0; i < 5; i++) {
|
|
16
160
|
if (!API_KEY) {
|
|
17
|
-
const mcpPath =
|
|
18
|
-
if (
|
|
161
|
+
const mcpPath = path2.join(searchDir, ".mcp.json");
|
|
162
|
+
if (fs2.existsSync(mcpPath)) {
|
|
19
163
|
try {
|
|
20
|
-
const content =
|
|
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 =
|
|
35
|
-
if (
|
|
178
|
+
const csConfigPath = path2.join(searchDir, ".contextstream", "config.json");
|
|
179
|
+
if (fs2.existsSync(csConfigPath)) {
|
|
36
180
|
try {
|
|
37
|
-
const content =
|
|
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 =
|
|
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 =
|
|
52
|
-
if (
|
|
195
|
+
const homeMcpPath = path2.join(homedir2(), ".mcp.json");
|
|
196
|
+
if (fs2.existsSync(homeMcpPath)) {
|
|
53
197
|
try {
|
|
54
|
-
const content =
|
|
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") {
|