@minhpnq1807/contextos 0.5.42 → 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.
- package/CHANGELOG.md +26 -0
- package/README.md +41 -10
- package/bin/ctx.js +136 -39
- package/package.json +2 -1
- package/plugins/ctx/.codex-plugin/plugin.json +1 -1
- package/plugins/ctx/bin/on-prompt.js +12 -9
- package/plugins/ctx/lib/analyzer.js +12 -94
- package/plugins/ctx/lib/auto-warm.js +74 -0
- package/plugins/ctx/lib/ctx-mcp-client.js +58 -9
- package/plugins/ctx/lib/embedding-scorer.js +109 -7
- package/plugins/ctx/lib/file-embedding-retriever.js +20 -23
- package/plugins/ctx/lib/global-hooks.js +20 -0
- package/plugins/ctx/lib/graph-retriever.js +82 -15
- package/plugins/ctx/lib/graph-strategy.js +107 -0
- package/plugins/ctx/lib/hook-io.js +29 -1
- package/plugins/ctx/lib/import-graph.js +37 -40
- package/plugins/ctx/lib/mcp-proxy-install.js +18 -90
- package/plugins/ctx/lib/output-config.js +85 -0
- package/plugins/ctx/lib/package-install.js +8 -0
- package/plugins/ctx/lib/prompt-hook.js +95 -20
- package/plugins/ctx/lib/ruler-sync.js +9 -71
- package/plugins/ctx/lib/scheduler.js +33 -21
- package/plugins/ctx/lib/score-context.js +60 -32
- package/plugins/ctx/lib/setup-wizard.js +5 -2
- package/plugins/ctx/lib/shell-runner.js +88 -0
- package/plugins/ctx/lib/skill-discoverer.js +110 -10
- package/plugins/ctx/lib/skillshare-sync.js +51 -2
- package/plugins/ctx/lib/stop-hook.js +2 -3
- package/plugins/ctx/lib/toml-config.js +116 -0
- package/plugins/ctx/mcp/server.js +6 -2
|
@@ -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
|
-
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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:
|
|
295
|
+
seedFiles: embeddingFiles.slice(0, limit),
|
|
296
|
+
dataDir,
|
|
326
297
|
limit: Math.max(limit * 2, 6)
|
|
327
298
|
});
|
|
328
|
-
const seedFiles = mergeLocalFileCandidates([...
|
|
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 =
|
|
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 =
|
|
39
|
+
const client = createConnection(socketPath);
|
|
24
40
|
let raw = "";
|
|
25
|
-
|
|
41
|
+
let responseTimer;
|
|
42
|
+
const connectTimer = setTimeout(() => {
|
|
26
43
|
client.destroy();
|
|
27
|
-
reject(new Error(`ctx-mcp bridge timed out after ${
|
|
28
|
-
},
|
|
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(
|
|
59
|
+
clearTimeout(connectTimer);
|
|
60
|
+
clearTimeout(responseTimer);
|
|
38
61
|
try {
|
|
39
|
-
|
|
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(
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
28
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
}
|