@minhpnq1807/contextos 0.5.41 → 0.5.44

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.
@@ -1,4 +1,3 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
2
  import { findGraphRelevantFiles, mergeRelevantFiles } from "./graph-retriever.js";
4
3
  import { expandImportGraph } from "./import-graph.js";
@@ -15,10 +14,6 @@ const IMPORTANT_WORDS = [
15
14
  "luon", "khong bao gio", "bat buoc", "quan trong"
16
15
  ];
17
16
 
18
- const IGNORE_DIRS = new Set([
19
- ".git", ".next", ".turbo", "coverage", "dist", "build", "node_modules", "vendor"
20
- ]);
21
-
22
17
  const SEMANTIC_ALIASES = {
23
18
  duyet: ["moderation", "moderate", "review", "approve", "approval", "approved", "reject", "rejected"],
24
19
  kiem: ["check", "verify", "validation", "validate"],
@@ -49,8 +44,6 @@ const SEMANTIC_ALIASES = {
49
44
  recheck: ["check", "verify", "review"]
50
45
  };
51
46
 
52
- const MODERATION_TOKENS = new Set(["moderation", "moderate", "content-moderation", "approval", "approved", "reject", "rejected", "needs_review"]);
53
-
54
47
  const SYSTEM_USER_RULE_PATTERNS = [
55
48
  /\ball\s+shell\s+commands?\s+must\s+run\s+as\b/i,
56
49
  /\bcommands?\s+must\s+run\s+as\b/i,
@@ -287,45 +280,23 @@ export async function findRelevantFiles({
287
280
  fileEmbeddingTimeoutMs,
288
281
  fileEmbeddingOptions = {}
289
282
  } = {}) {
290
- const rawTaskTokens = new Set(tokenize(task));
291
- if (!rawTaskTokens.size) return [];
292
-
293
- const candidates = [];
294
- walkFiles(cwd, (filePath) => {
295
- const rel = path.relative(cwd, filePath);
296
- const fileTokens = new Set(tokenize(rel));
297
- const match = scoreFileTokens({ rawTaskTokens, fileTokens });
298
- if (match.score > 0) {
299
- candidates.push({
300
- path: rel,
301
- score: match.score,
302
- reasons: match.reasons
303
- });
304
- }
305
- });
283
+ if (!String(task || "").trim()) return [];
306
284
 
307
- const heuristicFiles = candidates
308
- .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
309
- .slice(0, Math.max(limit * 2, 6));
310
- const hasHighConfidenceHeuristics =
311
- heuristicFiles.length >= limit &&
312
- Number(heuristicFiles[0]?.score || 0) >= 8;
313
- const embeddingFiles = hasHighConfidenceHeuristics
314
- ? []
315
- : await embeddingFileFinder({
316
- cwd,
317
- task,
318
- dataDir,
319
- timeoutMs: fileEmbeddingTimeoutMs,
320
- embeddingOptions: fileEmbeddingOptions,
321
- limit: Math.max(limit * 2, 6)
322
- });
285
+ const embeddingFiles = await embeddingFileFinder({
286
+ cwd,
287
+ task,
288
+ dataDir,
289
+ timeoutMs: fileEmbeddingTimeoutMs,
290
+ embeddingOptions: fileEmbeddingOptions,
291
+ limit: Math.max(limit * 2, 6)
292
+ });
323
293
  const importGraphFiles = expandImportGraph({
324
294
  cwd,
325
- seedFiles: mergeLocalFileCandidates([...heuristicFiles, ...embeddingFiles]).slice(0, limit),
295
+ seedFiles: embeddingFiles.slice(0, limit),
296
+ dataDir,
326
297
  limit: Math.max(limit * 2, 6)
327
298
  });
328
- const seedFiles = mergeLocalFileCandidates([...heuristicFiles, ...embeddingFiles, ...importGraphFiles])
299
+ const seedFiles = mergeLocalFileCandidates([...embeddingFiles, ...importGraphFiles])
329
300
  .slice(0, Math.max(limit * 3, 9));
330
301
 
331
302
  const graphFiles = findGraphRelevantFiles({
@@ -353,56 +324,3 @@ function mergeLocalFileCandidates(files) {
353
324
  }
354
325
  return [...byPath.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
355
326
  }
356
-
357
- function scoreFileTokens({ rawTaskTokens, fileTokens }) {
358
- let score = 0;
359
- const reasons = new Set();
360
- const hasModerationIntent = rawTaskTokens.has("kiem-duyet") || rawTaskTokens.has("kiemduyet") || rawTaskTokens.has("duyet");
361
- const hasUploadIntent = rawTaskTokens.has("upload") || rawTaskTokens.has("tai-len") || rawTaskTokens.has("tailen");
362
-
363
- for (const token of rawTaskTokens) {
364
- if (fileTokens.has(token)) {
365
- score += 3;
366
- reasons.add(token);
367
- }
368
- for (const alias of SEMANTIC_ALIASES[token] || []) {
369
- if (fileTokens.has(alias)) {
370
- score += 2;
371
- reasons.add(`${token}->${alias}`);
372
- }
373
- }
374
- }
375
-
376
- if (hasModerationIntent && [...fileTokens].some((token) => MODERATION_TOKENS.has(token))) {
377
- score += 6;
378
- reasons.add("domain:moderation");
379
- }
380
-
381
- if (hasUploadIntent && (fileTokens.has("upload") || fileTokens.has("uploaded") || fileTokens.has("resource"))) {
382
- score += 2;
383
- reasons.add("domain:upload");
384
- }
385
-
386
- return { score, reasons: [...reasons] };
387
- }
388
-
389
- function walkFiles(directory, onFile, depth = 0) {
390
- if (depth > 6) return;
391
- let entries = [];
392
- try {
393
- entries = fs.readdirSync(directory, { withFileTypes: true });
394
- } catch {
395
- return;
396
- }
397
- for (const entry of entries) {
398
- if (entry.name.startsWith(".") && entry.name !== ".github") {
399
- if (entry.name !== ".codex") continue;
400
- }
401
- const fullPath = path.join(directory, entry.name);
402
- if (entry.isDirectory()) {
403
- if (!IGNORE_DIRS.has(entry.name)) walkFiles(fullPath, onFile, depth + 1);
404
- } else if (entry.isFile()) {
405
- onFile(fullPath);
406
- }
407
- }
408
- }
@@ -0,0 +1,74 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const DEFAULT_COOLDOWN_MS = 15 * 60 * 1000;
7
+
8
+ export function maybeAutoWarmWorkspace({
9
+ cwd = process.cwd(),
10
+ prompt = "",
11
+ dataDir,
12
+ reason,
13
+ now = Date.now(),
14
+ spawnProcess = spawn,
15
+ cooldownMs = Number(process.env.CONTEXTOS_AUTO_WARM_COOLDOWN_MS || DEFAULT_COOLDOWN_MS)
16
+ } = {}) {
17
+ if (process.env.CONTEXTOS_AUTO_WARM === "0") return { status: "disabled" };
18
+ if (!dataDir) return { status: "skipped", reason: "missing-data-dir" };
19
+ if (!String(prompt || "").trim()) return { status: "skipped", reason: "missing-prompt" };
20
+ if (!shouldAutoWarm(reason)) return { status: "skipped", reason: "not-actionable" };
21
+
22
+ const markerPath = path.join(dataDir, "auto-warm.json");
23
+ const existing = readJson(markerPath);
24
+ if (existing?.startedAt) {
25
+ const ageMs = now - Date.parse(existing.startedAt);
26
+ if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < cooldownMs) {
27
+ return { status: "cooldown", markerPath, ageMs };
28
+ }
29
+ }
30
+
31
+ try {
32
+ fs.mkdirSync(dataDir, { recursive: true });
33
+ fs.writeFileSync(markerPath, `${JSON.stringify({
34
+ startedAt: new Date(now).toISOString(),
35
+ cwd,
36
+ reason,
37
+ prompt: String(prompt).slice(0, 300)
38
+ }, null, 2)}\n`, "utf8");
39
+ } catch {
40
+ return { status: "skipped", reason: "marker-write-failed" };
41
+ }
42
+
43
+ const child = spawnProcess(process.execPath, [ctxBinPath(), "autowarm", "--", prompt], {
44
+ cwd,
45
+ detached: true,
46
+ stdio: "ignore",
47
+ env: {
48
+ ...process.env,
49
+ CONTEXTOS_AUTO_WARM_CHILD: "1"
50
+ }
51
+ });
52
+ child.on?.("error", () => {});
53
+ child.unref?.();
54
+ return { status: "started", pid: child.pid, markerPath };
55
+ }
56
+
57
+ function shouldAutoWarm(reason) {
58
+ if (reason === "no-context-candidates") return true;
59
+ if (reason === "enabled-sections-empty-after-formatting") return true;
60
+ return false;
61
+ }
62
+
63
+ function ctxBinPath() {
64
+ const here = path.dirname(fileURLToPath(import.meta.url));
65
+ return path.resolve(here, "../../../bin/ctx.js");
66
+ }
67
+
68
+ function readJson(filePath) {
69
+ try {
70
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
@@ -4,50 +4,99 @@ import path from "node:path";
4
4
 
5
5
  import { defaultDataRoot } from "./workspace-data.js";
6
6
 
7
- const DEFAULT_TIMEOUT_MS = 1000;
7
+ const DEFAULT_TIMEOUT_MS = 2000;
8
+ const DEFAULT_CONNECT_TIMEOUT_MS = 100;
9
+ export const CTX_MCP_BRIDGE_REVISION = 2;
8
10
 
9
11
  export function ctxMcpSocketPath(dataDir = defaultDataDir()) {
10
12
  return path.join(dataDir, "ctx-mcp.sock");
11
13
  }
12
14
 
15
+ export function invalidateCtxMcpSocket(dataDir = defaultDataDir()) {
16
+ const socketPath = ctxMcpSocketPath(dataDir);
17
+ if (!fs.existsSync(socketPath)) return false;
18
+ try {
19
+ fs.rmSync(socketPath, { force: true });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
13
26
  export async function callCtxScoreContext(payload, {
14
27
  dataDir = defaultDataDir(),
15
- timeoutMs = Number(process.env.CONTEXTOS_MCP_BRIDGE_TIMEOUT_MS || DEFAULT_TIMEOUT_MS)
28
+ timeoutMs = Number(process.env.CONTEXTOS_MCP_BRIDGE_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
29
+ connectTimeoutMs = Number(process.env.CONTEXTOS_MCP_CONNECT_TIMEOUT_MS || DEFAULT_CONNECT_TIMEOUT_MS),
30
+ createConnection = net.createConnection
16
31
  } = {}) {
17
32
  const socketPath = ctxMcpSocketPath(dataDir);
18
33
  if (!fs.existsSync(socketPath)) {
19
34
  throw new Error(`ctx-mcp bridge socket not found: ${socketPath}`);
20
35
  }
36
+ const socketIdentity = statIdentity(socketPath);
21
37
 
22
38
  return new Promise((resolve, reject) => {
23
- const client = net.createConnection(socketPath);
39
+ const client = createConnection(socketPath);
24
40
  let raw = "";
25
- const timer = setTimeout(() => {
41
+ let responseTimer;
42
+ const connectTimer = setTimeout(() => {
26
43
  client.destroy();
27
- reject(new Error(`ctx-mcp bridge timed out after ${timeoutMs}ms`));
28
- }, timeoutMs);
44
+ reject(new Error(`ctx-mcp bridge connect timed out after ${connectTimeoutMs}ms`));
45
+ }, connectTimeoutMs);
29
46
 
30
47
  client.on("connect", () => {
48
+ clearTimeout(connectTimer);
49
+ responseTimer = setTimeout(() => {
50
+ client.destroy();
51
+ reject(new Error(`ctx-mcp bridge timed out after ${timeoutMs}ms`));
52
+ }, timeoutMs);
31
53
  client.write(`${JSON.stringify(payload)}\n`);
32
54
  });
33
55
  client.on("data", (chunk) => {
34
56
  raw += chunk.toString("utf8");
35
57
  });
36
58
  client.on("end", () => {
37
- clearTimeout(timer);
59
+ clearTimeout(connectTimer);
60
+ clearTimeout(responseTimer);
38
61
  try {
39
- resolve(JSON.parse(raw || "{}"));
62
+ const response = JSON.parse(raw || "{}");
63
+ if (response.bridgeRevision !== CTX_MCP_BRIDGE_REVISION) {
64
+ invalidateSocketIfUnchanged(socketPath, socketIdentity);
65
+ reject(new Error(`ctx-mcp bridge revision mismatch: expected ${CTX_MCP_BRIDGE_REVISION}, received ${response.bridgeRevision || "missing"}`));
66
+ return;
67
+ }
68
+ resolve(response);
40
69
  } catch (error) {
41
70
  reject(error);
42
71
  }
43
72
  });
44
73
  client.on("error", (error) => {
45
- clearTimeout(timer);
74
+ clearTimeout(connectTimer);
75
+ clearTimeout(responseTimer);
46
76
  reject(error);
47
77
  });
48
78
  });
49
79
  }
50
80
 
81
+ function invalidateSocketIfUnchanged(socketPath, expectedIdentity) {
82
+ if (!expectedIdentity || statIdentity(socketPath) !== expectedIdentity) return false;
83
+ try {
84
+ fs.rmSync(socketPath, { force: true });
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ function statIdentity(filePath) {
92
+ try {
93
+ const stat = fs.statSync(filePath);
94
+ return `${stat.dev}:${stat.ino}`;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
51
100
  function defaultDataDir() {
52
101
  return defaultDataRoot();
53
102
  }
@@ -74,6 +74,54 @@ export async function warmRuleEmbeddings({
74
74
  return { count: texts.length, cachePath: cache.path };
75
75
  }
76
76
 
77
+ export async function searchIndexedEmbeddings({
78
+ kind,
79
+ task = "",
80
+ dataDir = defaultDataRoot(),
81
+ timeoutMs = Number(process.env.CONTEXTOS_EMBEDDING_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
82
+ allowRemote = process.env.CONTEXTOS_EMBEDDING_ALLOW_REMOTE === "1",
83
+ enabled = process.env.CONTEXTOS_EMBEDDINGS !== "0"
84
+ } = {}) {
85
+ if (!enabled || !kind || !String(task || "").trim()) return { items: [], status: "disabled" };
86
+ const cachePath = path.join(dataDir, "embeddings.db");
87
+ if (!allowRemote && !fs.existsSync(cachePath)) return { items: [], status: "cold-cache", cachePath };
88
+
89
+ try {
90
+ return await withTimeout(searchIndexed({ kind, task, dataDir, allowRemote }), timeoutMs);
91
+ } catch (error) {
92
+ return { items: [], status: "fallback", error: error?.message || String(error) };
93
+ }
94
+ }
95
+
96
+ export async function warmIndexedEmbeddings({
97
+ kind,
98
+ items = [],
99
+ task = "",
100
+ dataDir = defaultDataRoot(),
101
+ sources = [],
102
+ allowRemote = true
103
+ } = {}) {
104
+ if (!kind || !items.length) return { count: 0, cachePath: path.join(dataDir, "embeddings.db") };
105
+ if (!allowRemote && !isModelCacheReady(dataDir)) {
106
+ return { count: 0, cachePath: path.join(dataDir, "embeddings.db"), status: "missing-model" };
107
+ }
108
+
109
+ const cache = await openEmbeddingCache(dataDir);
110
+ const embedder = await getExtractor({ allowRemote, dataDir });
111
+ if (String(task || "").trim()) await getCachedEmbedding({ cache, embedder, text: task, sources });
112
+
113
+ const indexed = [];
114
+ for (const item of items) {
115
+ const text = String(item.text || "");
116
+ if (!item.id || !text.trim()) continue;
117
+ const vector = await getCachedEmbedding({ cache, embedder, text, sources });
118
+ indexed.push({ id: item.id, text, vector });
119
+ }
120
+ cache.replaceIndex(kind, indexed);
121
+ cache.close();
122
+ return { count: indexed.length, cachePath: cache.path };
123
+ }
124
+
77
125
  async function enhanceRuleScores(rules, task, { dataDir, sources, allowRemote }) {
78
126
  const cache = await openEmbeddingCache(dataDir);
79
127
  const embedder = await getExtractor({ allowRemote, dataDir });
@@ -113,6 +161,20 @@ async function enhanceRuleScores(rules, task, { dataDir, sources, allowRemote })
113
161
  };
114
162
  }
115
163
 
164
+ async function searchIndexed({ kind, task, dataDir, allowRemote }) {
165
+ const cache = await openEmbeddingCache(dataDir);
166
+ const embedder = await getExtractor({ allowRemote, dataDir });
167
+ const taskEmbedding = await getCachedEmbedding({ cache, embedder, text: task, sources: [] });
168
+ const items = cache.listIndexed(kind)
169
+ .map((item) => ({
170
+ ...item,
171
+ embeddingScore: Number(similarityToScore(cosine(taskEmbedding, item.vector)).toFixed(3))
172
+ }))
173
+ .sort((a, b) => b.embeddingScore - a.embeddingScore || a.id.localeCompare(b.id));
174
+ cache.close();
175
+ return { items, status: "enabled", model: DEFAULT_MODEL, cachePath: cache.path };
176
+ }
177
+
116
178
  async function getExtractor({ allowRemote, dataDir }) {
117
179
  const cacheDir = modelCacheDir(dataDir);
118
180
  const key = `${allowRemote ? "remote" : "local"}:${cacheDir}`;
@@ -183,6 +245,30 @@ export async function openEmbeddingCache(dataDir) {
183
245
  );
184
246
  writeDatabaseAtomically(cachePath, db);
185
247
  },
248
+ listIndexed(kind) {
249
+ const stmt = db.prepare("SELECT id, text, vector FROM embedding_index WHERE kind = ? AND model = ?");
250
+ const items = [];
251
+ try {
252
+ stmt.bind([kind, DEFAULT_MODEL]);
253
+ while (stmt.step()) {
254
+ const row = stmt.getAsObject();
255
+ items.push({ id: row.id, text: row.text, vector: JSON.parse(row.vector) });
256
+ }
257
+ } finally {
258
+ stmt.free();
259
+ }
260
+ return items;
261
+ },
262
+ replaceIndex(kind, items) {
263
+ db.run("DELETE FROM embedding_index WHERE kind = ? AND model = ?", [kind, DEFAULT_MODEL]);
264
+ for (const item of items) {
265
+ db.run(
266
+ "INSERT INTO embedding_index (kind, id, text, model, vector, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
267
+ [kind, item.id, item.text, DEFAULT_MODEL, JSON.stringify(item.vector), new Date().toISOString()]
268
+ );
269
+ }
270
+ writeDatabaseAtomically(cachePath, db);
271
+ },
186
272
  close() {
187
273
  writeDatabaseAtomically(cachePath, db);
188
274
  db.close();
@@ -218,6 +304,17 @@ function ensureEmbeddingSchema(db) {
218
304
  updated_at TEXT NOT NULL
219
305
  )
220
306
  `);
307
+ db.run(`
308
+ CREATE TABLE IF NOT EXISTS embedding_index (
309
+ kind TEXT NOT NULL,
310
+ id TEXT NOT NULL,
311
+ text TEXT NOT NULL,
312
+ model TEXT NOT NULL,
313
+ vector TEXT NOT NULL,
314
+ updated_at TEXT NOT NULL,
315
+ PRIMARY KEY (kind, id, model)
316
+ )
317
+ `);
221
318
  }
222
319
 
223
320
  function openSqlDatabase(SQL, cachePath) {
@@ -316,11 +413,16 @@ function similarityToScore(similarity) {
316
413
  return Math.max(0, Math.min(1, (similarity + 1) / 2));
317
414
  }
318
415
 
319
- function withTimeout(promise, timeoutMs) {
320
- return Promise.race([
321
- promise,
322
- new Promise((_, reject) => {
323
- setTimeout(() => reject(new Error(`embedding scorer timed out after ${timeoutMs}ms`)), timeoutMs);
324
- })
325
- ]);
416
+ async function withTimeout(promise, timeoutMs) {
417
+ let timer;
418
+ try {
419
+ return await Promise.race([
420
+ promise,
421
+ new Promise((_, reject) => {
422
+ timer = setTimeout(() => reject(new Error(`embedding scorer timed out after ${timeoutMs}ms`)), timeoutMs);
423
+ })
424
+ ]);
425
+ } finally {
426
+ clearTimeout(timer);
427
+ }
326
428
  }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { enhanceRuleScoresWithEmbeddings, isModelCacheReady, warmRuleEmbeddings } from "./embedding-scorer.js";
3
+ import { isModelCacheReady, searchIndexedEmbeddings, warmIndexedEmbeddings } from "./embedding-scorer.js";
4
+ import { rebuildImportGraphIndex } from "./import-graph.js";
4
5
 
5
6
  const SOURCE_EXTENSIONS = new Set([
6
7
  ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".sql", ".md", ".json"
@@ -8,7 +9,7 @@ const SOURCE_EXTENSIONS = new Set([
8
9
  const IGNORE_DIRS = new Set([
9
10
  ".git", ".next", ".turbo", "coverage", "dist", "build", "node_modules", "vendor"
10
11
  ]);
11
- const DEFAULT_TIMEOUT_MS = 80;
12
+ const DEFAULT_TIMEOUT_MS = 1000;
12
13
  const DEFAULT_MAX_FILES = 1200;
13
14
 
14
15
  export async function findEmbeddingRelevantFiles({
@@ -18,27 +19,17 @@ export async function findEmbeddingRelevantFiles({
18
19
  limit = 10,
19
20
  timeoutMs = Number(process.env.CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
20
21
  maxFiles = Number(process.env.CONTEXTOS_FILE_EMBEDDING_MAX_FILES || DEFAULT_MAX_FILES),
21
- embeddingOptions = {}
22
+ embeddingOptions = {},
23
+ indexedSearcher = searchIndexedEmbeddings
22
24
  } = {}) {
23
25
  if (process.env.CONTEXTOS_FILE_EMBEDDINGS === "0") return [];
24
26
  if (!dataDir) return [];
25
27
  if (!String(task || "").trim()) return [];
26
28
 
27
- const files = listSourceFiles(cwd, { maxFiles });
28
- if (!files.length) return [];
29
-
30
- const fileRules = files.map((filePath, index) => ({
31
- id: `f${index + 1}`,
32
- content: fileSearchText(filePath),
33
- path: filePath,
34
- score: 0,
35
- reasons: [],
36
- originalOrder: index
37
- }));
38
-
39
- const result = await enhanceRuleScoresWithEmbeddings(fileRules, task, {
29
+ const result = await indexedSearcher({
30
+ kind: fileIndexKind(cwd),
31
+ task,
40
32
  dataDir,
41
- sources: [path.join(cwd, "AGENTS.md")],
42
33
  timeoutMs,
43
34
  allowRemote: false,
44
35
  ...embeddingOptions
@@ -46,12 +37,12 @@ export async function findEmbeddingRelevantFiles({
46
37
 
47
38
  if (result.status !== "enabled") return [];
48
39
 
49
- return result.rules
40
+ return result.items
50
41
  .filter((rule) => Number(rule.embeddingScore || 0) >= 0.45)
51
- .sort((a, b) => Number(b.embeddingScore || 0) - Number(a.embeddingScore || 0) || a.path.localeCompare(b.path))
42
+ .sort((a, b) => Number(b.embeddingScore || 0) - Number(a.embeddingScore || 0) || a.id.localeCompare(b.id))
52
43
  .slice(0, limit)
53
44
  .map((rule) => ({
54
- path: rule.path,
45
+ path: rule.id,
55
46
  score: Math.round(Number(rule.embeddingScore || 0) * 10),
56
47
  source: "embedding",
57
48
  reasons: [`file-embedding:${Number(rule.embeddingScore || 0).toFixed(2)}`]
@@ -67,9 +58,11 @@ export async function warmFileEmbeddings({
67
58
  if (!dataDir) return { count: 0, cachePath: null };
68
59
  if (!allowRemote && !isModelCacheReady(dataDir)) return { count: 0, cachePath: null, status: "missing-model" };
69
60
  const files = listSourceFiles(cwd, { maxFiles });
70
- const rules = files.map((filePath) => ({ content: fileSearchText(filePath) }));
71
- return warmRuleEmbeddings({
72
- rules,
61
+ rebuildImportGraphIndex({ cwd, files, dataDir });
62
+ const items = files.map((filePath) => ({ id: filePath, text: fileSearchText(filePath) }));
63
+ return warmIndexedEmbeddings({
64
+ kind: fileIndexKind(cwd),
65
+ items,
73
66
  task: "project file semantic retrieval",
74
67
  dataDir,
75
68
  sources: [path.join(cwd, "AGENTS.md")],
@@ -77,6 +70,10 @@ export async function warmFileEmbeddings({
77
70
  });
78
71
  }
79
72
 
73
+ function fileIndexKind(cwd) {
74
+ return `file:${path.resolve(cwd)}`;
75
+ }
76
+
80
77
  function listSourceFiles(cwd, { maxFiles }) {
81
78
  const files = [];
82
79
  walkFiles(cwd, (filePath) => {
@@ -4,6 +4,8 @@ import path from "node:path";
4
4
  const CONTEXTOS_COMMAND_MARKER = "/contextos/plugins/ctx/bin/on-";
5
5
  const QUIET_CODE_REVIEW_GRAPH_STATUS_COMMAND =
6
6
  "git rev-parse --git-dir >/dev/null 2>&1 && code-review-graph status >/dev/null 2>&1 || true";
7
+ const DRAINED_CODE_REVIEW_GRAPH_UPDATE_COMMAND =
8
+ "cat >/dev/null; git rev-parse --git-dir >/dev/null 2>&1 && code-review-graph update --skip-flows || true";
7
9
 
8
10
  function shellQuote(value) {
9
11
  const s = String(value);
@@ -52,6 +54,23 @@ function quietCodeReviewGraphSessionStart(entries = []) {
52
54
  }));
53
55
  }
54
56
 
57
+ function drainCodeReviewGraphPostToolUse(entries = []) {
58
+ return entries.map((entry) => ({
59
+ ...entry,
60
+ hooks: (entry.hooks || []).map((hook) => {
61
+ if (typeof hook.command === "string" && hook.command.includes("code-review-graph update --skip-flows")) {
62
+ return {
63
+ ...hook,
64
+ command: hook.command.includes("cat >/dev/null")
65
+ ? hook.command
66
+ : DRAINED_CODE_REVIEW_GRAPH_UPDATE_COMMAND
67
+ };
68
+ }
69
+ return hook;
70
+ })
71
+ }));
72
+ }
73
+
55
74
  function commandFor(marketplaceRoot, scriptName, { injectPromptContext = true } = {}) {
56
75
  const envPrefix = scriptName === "on-prompt.js" && !injectPromptContext ? "CONTEXTOS_INJECT=0 " : "";
57
76
  return `${envPrefix}node ${shellQuote(path.join(marketplaceRoot, "plugins", "ctx", "bin", scriptName))}`;
@@ -105,6 +124,7 @@ export function buildGlobalHooksConfig(existingConfig, { marketplaceRoot, inject
105
124
  }
106
125
 
107
126
  config.hooks.SessionStart = quietCodeReviewGraphSessionStart(config.hooks.SessionStart);
127
+ config.hooks.PostToolUse = drainCodeReviewGraphPostToolUse(config.hooks.PostToolUse);
108
128
 
109
129
  return config;
110
130
  }