@minhpnq1807/contextos 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/CHANGELOG.md +16 -0
- package/DEMO.md +57 -0
- package/LICENSE +21 -0
- package/README.md +388 -0
- package/bin/ctx.js +261 -0
- package/package.json +63 -0
- package/plugins/ctx/.codex-plugin/plugin.json +35 -0
- package/plugins/ctx/.mcp.json +10 -0
- package/plugins/ctx/bin/on-prompt.js +25 -0
- package/plugins/ctx/bin/on-session-start.js +22 -0
- package/plugins/ctx/bin/on-stop.js +17 -0
- package/plugins/ctx/hooks.json +35 -0
- package/plugins/ctx/lib/analyzer.js +321 -0
- package/plugins/ctx/lib/ctx-mcp-client.js +52 -0
- package/plugins/ctx/lib/embedding-scorer.js +248 -0
- package/plugins/ctx/lib/file-embedding-retriever.js +116 -0
- package/plugins/ctx/lib/fs-utils.js +28 -0
- package/plugins/ctx/lib/global-hooks.js +110 -0
- package/plugins/ctx/lib/graph-retriever.js +226 -0
- package/plugins/ctx/lib/hook-io.js +65 -0
- package/plugins/ctx/lib/import-graph.js +124 -0
- package/plugins/ctx/lib/measure.js +263 -0
- package/plugins/ctx/lib/prompt-hook.js +72 -0
- package/plugins/ctx/lib/reader.js +57 -0
- package/plugins/ctx/lib/reporter.js +105 -0
- package/plugins/ctx/lib/scheduler.js +45 -0
- package/plugins/ctx/lib/score-context.js +55 -0
- package/plugins/ctx/lib/stats.js +127 -0
- package/plugins/ctx/lib/stop-hook.js +32 -0
- package/plugins/ctx/mcp/contextos-server.js +50 -0
- package/plugins/ctx/mcp/server.js +83 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { enhanceRuleScoresWithEmbeddings, warmRuleEmbeddings } from "./embedding-scorer.js";
|
|
4
|
+
|
|
5
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
6
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".sql", ".md", ".json"
|
|
7
|
+
]);
|
|
8
|
+
const IGNORE_DIRS = new Set([
|
|
9
|
+
".git", ".next", ".turbo", "coverage", "dist", "build", "node_modules", "vendor"
|
|
10
|
+
]);
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 80;
|
|
12
|
+
const DEFAULT_MAX_FILES = 1200;
|
|
13
|
+
|
|
14
|
+
export async function findEmbeddingRelevantFiles({
|
|
15
|
+
cwd = process.cwd(),
|
|
16
|
+
task = "",
|
|
17
|
+
dataDir,
|
|
18
|
+
limit = 10,
|
|
19
|
+
timeoutMs = Number(process.env.CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
|
20
|
+
maxFiles = Number(process.env.CONTEXTOS_FILE_EMBEDDING_MAX_FILES || DEFAULT_MAX_FILES),
|
|
21
|
+
embeddingOptions = {}
|
|
22
|
+
} = {}) {
|
|
23
|
+
if (process.env.CONTEXTOS_FILE_EMBEDDINGS === "0") return [];
|
|
24
|
+
if (!dataDir) return [];
|
|
25
|
+
if (!String(task || "").trim()) return [];
|
|
26
|
+
|
|
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, {
|
|
40
|
+
dataDir,
|
|
41
|
+
sources: [path.join(cwd, "AGENTS.md")],
|
|
42
|
+
timeoutMs,
|
|
43
|
+
allowRemote: false,
|
|
44
|
+
...embeddingOptions
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (result.status !== "enabled") return [];
|
|
48
|
+
|
|
49
|
+
return result.rules
|
|
50
|
+
.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))
|
|
52
|
+
.slice(0, limit)
|
|
53
|
+
.map((rule) => ({
|
|
54
|
+
path: rule.path,
|
|
55
|
+
score: Math.round(Number(rule.embeddingScore || 0) * 10),
|
|
56
|
+
source: "embedding",
|
|
57
|
+
reasons: [`file-embedding:${Number(rule.embeddingScore || 0).toFixed(2)}`]
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function warmFileEmbeddings({
|
|
62
|
+
cwd = process.cwd(),
|
|
63
|
+
dataDir,
|
|
64
|
+
allowRemote = true,
|
|
65
|
+
maxFiles = Number(process.env.CONTEXTOS_FILE_EMBEDDING_MAX_FILES || DEFAULT_MAX_FILES)
|
|
66
|
+
} = {}) {
|
|
67
|
+
if (!dataDir) return { count: 0, cachePath: null };
|
|
68
|
+
const files = listSourceFiles(cwd, { maxFiles });
|
|
69
|
+
const rules = files.map((filePath) => ({ content: fileSearchText(filePath) }));
|
|
70
|
+
return warmRuleEmbeddings({
|
|
71
|
+
rules,
|
|
72
|
+
task: "project file semantic retrieval",
|
|
73
|
+
dataDir,
|
|
74
|
+
sources: [path.join(cwd, "AGENTS.md")],
|
|
75
|
+
allowRemote
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function listSourceFiles(cwd, { maxFiles }) {
|
|
80
|
+
const files = [];
|
|
81
|
+
walkFiles(cwd, (filePath) => {
|
|
82
|
+
if (files.length >= maxFiles) return;
|
|
83
|
+
files.push(path.relative(cwd, filePath));
|
|
84
|
+
});
|
|
85
|
+
return files;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function walkFiles(directory, onFile, depth = 0) {
|
|
89
|
+
if (depth > 7) return;
|
|
90
|
+
let entries = [];
|
|
91
|
+
try {
|
|
92
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
93
|
+
} catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (entry.name.startsWith(".") && entry.name !== ".github") continue;
|
|
99
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
100
|
+
|
|
101
|
+
const fullPath = path.join(directory, entry.name);
|
|
102
|
+
if (entry.isDirectory()) {
|
|
103
|
+
walkFiles(fullPath, onFile, depth + 1);
|
|
104
|
+
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
|
|
105
|
+
onFile(fullPath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fileSearchText(filePath) {
|
|
111
|
+
const normalized = String(filePath || "")
|
|
112
|
+
.replace(/\.[^.]+$/, "")
|
|
113
|
+
.replace(/[._/-]+/g, " ")
|
|
114
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2");
|
|
115
|
+
return `${filePath} ${normalized}`;
|
|
116
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function ensureDir(filePath) {
|
|
5
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function writeJsonFile(filePath, value) {
|
|
9
|
+
ensureDir(filePath);
|
|
10
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function appendJsonLine(filePath, value) {
|
|
14
|
+
ensureDir(filePath);
|
|
15
|
+
fs.appendFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readJsonFile(filePath) {
|
|
19
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function safeReadText(filePath) {
|
|
23
|
+
try {
|
|
24
|
+
return fs.readFileSync(filePath, "utf8");
|
|
25
|
+
} catch {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const CONTEXTOS_COMMAND_MARKER = "/contextos/plugins/ctx/bin/on-";
|
|
5
|
+
const QUIET_CODE_REVIEW_GRAPH_STATUS_COMMAND =
|
|
6
|
+
"git rev-parse --git-dir >/dev/null 2>&1 && code-review-graph status >/dev/null 2>&1 || true";
|
|
7
|
+
|
|
8
|
+
function shellQuote(value) {
|
|
9
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readHooksFile(hooksPath) {
|
|
13
|
+
if (!fs.existsSync(hooksPath)) return { hooks: {} };
|
|
14
|
+
const raw = fs.readFileSync(hooksPath, "utf8").trim();
|
|
15
|
+
if (!raw) return { hooks: {} };
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (!parsed.hooks || typeof parsed.hooks !== "object") parsed.hooks = {};
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isContextOSHookEntry(entry) {
|
|
22
|
+
return (entry.hooks || []).some((hook) => {
|
|
23
|
+
return typeof hook.command === "string" && hook.command.includes(CONTEXTOS_COMMAND_MARKER);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function withoutContextOSEntries(entries = []) {
|
|
28
|
+
return entries.filter((entry) => !isContextOSHookEntry(entry));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function quietCodeReviewGraphSessionStart(entries = []) {
|
|
32
|
+
return entries.map((entry) => ({
|
|
33
|
+
...entry,
|
|
34
|
+
hooks: (entry.hooks || []).map((hook) => {
|
|
35
|
+
if (typeof hook.command === "string" && hook.command.includes("code-review-graph status")) {
|
|
36
|
+
return {
|
|
37
|
+
...hook,
|
|
38
|
+
command: QUIET_CODE_REVIEW_GRAPH_STATUS_COMMAND
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return hook;
|
|
42
|
+
})
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function commandFor(marketplaceRoot, scriptName, { injectPromptContext = true } = {}) {
|
|
47
|
+
const envPrefix = scriptName === "on-prompt.js" && !injectPromptContext ? "CONTEXTOS_INJECT=0 " : "";
|
|
48
|
+
return `${envPrefix}node ${shellQuote(path.join(marketplaceRoot, "plugins", "ctx", "bin", scriptName))}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function contextOSEntry({ marketplaceRoot, scriptName, matcher, timeout, statusMessage, injectPromptContext = true }) {
|
|
52
|
+
const entry = {
|
|
53
|
+
hooks: [
|
|
54
|
+
{
|
|
55
|
+
type: "command",
|
|
56
|
+
command: commandFor(marketplaceRoot, scriptName, { injectPromptContext }),
|
|
57
|
+
timeout,
|
|
58
|
+
statusMessage
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (matcher) entry.matcher = matcher;
|
|
64
|
+
return entry;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildGlobalHooksConfig(existingConfig, { marketplaceRoot, injectPromptContext = true }) {
|
|
68
|
+
const config = existingConfig && typeof existingConfig === "object" ? structuredClone(existingConfig) : {};
|
|
69
|
+
if (!config.hooks || typeof config.hooks !== "object") config.hooks = {};
|
|
70
|
+
|
|
71
|
+
const additions = {
|
|
72
|
+
SessionStart: contextOSEntry({
|
|
73
|
+
marketplaceRoot,
|
|
74
|
+
scriptName: "on-session-start.js",
|
|
75
|
+
matcher: "startup|resume",
|
|
76
|
+
timeout: 10,
|
|
77
|
+
statusMessage: "ContextOS session start"
|
|
78
|
+
}),
|
|
79
|
+
UserPromptSubmit: contextOSEntry({
|
|
80
|
+
marketplaceRoot,
|
|
81
|
+
scriptName: "on-prompt.js",
|
|
82
|
+
timeout: 10,
|
|
83
|
+
statusMessage: "ContextOS scheduling context",
|
|
84
|
+
injectPromptContext
|
|
85
|
+
}),
|
|
86
|
+
Stop: contextOSEntry({
|
|
87
|
+
marketplaceRoot,
|
|
88
|
+
scriptName: "on-stop.js",
|
|
89
|
+
timeout: 10,
|
|
90
|
+
statusMessage: "ContextOS reporting"
|
|
91
|
+
})
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
for (const [eventName, entry] of Object.entries(additions)) {
|
|
95
|
+
config.hooks[eventName] = [...withoutContextOSEntries(config.hooks[eventName]), entry];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
config.hooks.SessionStart = quietCodeReviewGraphSessionStart(config.hooks.SessionStart);
|
|
99
|
+
|
|
100
|
+
return config;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function installGlobalHooks({ codexHome, marketplaceRoot, injectPromptContext = true }) {
|
|
104
|
+
const hooksPath = path.join(codexHome, "hooks.json");
|
|
105
|
+
const existing = readHooksFile(hooksPath);
|
|
106
|
+
const next = buildGlobalHooksConfig(existing, { marketplaceRoot, injectPromptContext });
|
|
107
|
+
fs.mkdirSync(path.dirname(hooksPath), { recursive: true });
|
|
108
|
+
fs.writeFileSync(hooksPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
109
|
+
return hooksPath;
|
|
110
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 80;
|
|
7
|
+
const MAX_GRAPH_QUERIES = 10;
|
|
8
|
+
const DEFAULT_CRG_PYTHON = path.join(
|
|
9
|
+
os.homedir(),
|
|
10
|
+
".local/share/pipx/venvs/code-review-graph/bin/python"
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const QUERY_STOP_WORDS = new Set([
|
|
14
|
+
"a", "an", "and", "are", "before", "cho", "for", "from", "into", "khi", "must",
|
|
15
|
+
"not", "the", "then", "this", "that", "trong", "use", "using", "with"
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export function hasGraphIndex(cwd = process.cwd()) {
|
|
19
|
+
return fs.existsSync(path.join(cwd, ".code-review-graph", "graph.db"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function findGraphRelevantFiles({
|
|
23
|
+
cwd = process.cwd(),
|
|
24
|
+
task = "",
|
|
25
|
+
rules = [],
|
|
26
|
+
seedFiles = [],
|
|
27
|
+
limit = 6,
|
|
28
|
+
timeoutMs = Number(process.env.CONTEXTOS_GRAPH_TIMEOUT_MS || DEFAULT_TIMEOUT_MS)
|
|
29
|
+
} = {}) {
|
|
30
|
+
if (process.env.CONTEXTOS_GRAPH_RETRIEVAL === "0") return [];
|
|
31
|
+
if (!hasGraphIndex(cwd)) return [];
|
|
32
|
+
|
|
33
|
+
const python = process.env.CONTEXTOS_CRG_PYTHON || DEFAULT_CRG_PYTHON;
|
|
34
|
+
if (!fs.existsSync(python)) return [];
|
|
35
|
+
|
|
36
|
+
const queries = buildGraphQueries({ task, rules, seedFiles });
|
|
37
|
+
if (!queries.length) return [];
|
|
38
|
+
|
|
39
|
+
const script = `
|
|
40
|
+
import json
|
|
41
|
+
import sys
|
|
42
|
+
|
|
43
|
+
from code_review_graph.tools.query import semantic_search_nodes
|
|
44
|
+
|
|
45
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
46
|
+
repo_root = payload.get("repoRoot")
|
|
47
|
+
queries = payload.get("queries") or []
|
|
48
|
+
limit = int(payload.get("limit") or 8)
|
|
49
|
+
seen = set()
|
|
50
|
+
results = []
|
|
51
|
+
|
|
52
|
+
for query in queries:
|
|
53
|
+
try:
|
|
54
|
+
response = semantic_search_nodes(
|
|
55
|
+
query=query,
|
|
56
|
+
repo_root=repo_root,
|
|
57
|
+
detail_level="minimal",
|
|
58
|
+
limit=limit,
|
|
59
|
+
)
|
|
60
|
+
except Exception:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
for item in response.get("results", []) or []:
|
|
64
|
+
file_path = item.get("file_path") or item.get("path")
|
|
65
|
+
if not file_path:
|
|
66
|
+
continue
|
|
67
|
+
key = (file_path, query)
|
|
68
|
+
if key in seen:
|
|
69
|
+
continue
|
|
70
|
+
seen.add(key)
|
|
71
|
+
results.append({
|
|
72
|
+
"path": file_path,
|
|
73
|
+
"query": query,
|
|
74
|
+
"name": item.get("name"),
|
|
75
|
+
"kind": item.get("kind"),
|
|
76
|
+
"score": item.get("score"),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
print(json.dumps(results))
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const output = execFileSync(python, ["-c", script], {
|
|
84
|
+
cwd,
|
|
85
|
+
input: JSON.stringify({ repoRoot: cwd, queries, limit }),
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
timeout: timeoutMs,
|
|
88
|
+
env: {
|
|
89
|
+
...process.env,
|
|
90
|
+
MPLCONFIGDIR: process.env.MPLCONFIGDIR || path.join(os.tmpdir(), "contextos-mpl")
|
|
91
|
+
},
|
|
92
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
93
|
+
});
|
|
94
|
+
const raw = JSON.parse(output || "[]");
|
|
95
|
+
return mergeGraphResults({ cwd, results: raw, limit });
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildGraphQueries({ task = "", rules = [], seedFiles = [], maxQueries = MAX_GRAPH_QUERIES } = {}) {
|
|
102
|
+
const queries = [];
|
|
103
|
+
addQuery(queries, task);
|
|
104
|
+
|
|
105
|
+
for (const file of seedFiles.slice(0, 5)) {
|
|
106
|
+
const filePath = file.path || "";
|
|
107
|
+
const basename = path.basename(filePath).replace(/\.[^.]+$/, "");
|
|
108
|
+
addQuery(queries, basename);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const topRules = seedFiles.length ? [] : [...rules]
|
|
112
|
+
.filter((rule) => Number(rule.score || 0) >= 0.1)
|
|
113
|
+
.sort((a, b) => Number(b.score || 0) - Number(a.score || 0))
|
|
114
|
+
.slice(0, 4);
|
|
115
|
+
|
|
116
|
+
for (const rule of topRules) {
|
|
117
|
+
const content = rule.content || "";
|
|
118
|
+
for (const identifier of extractIdentifiers(content)) addQuery(queries, identifier);
|
|
119
|
+
|
|
120
|
+
const terms = extractTerms(content);
|
|
121
|
+
for (const phrase of importantPhrases(terms)) addQuery(queries, phrase);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return queries.slice(0, maxQueries);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function mergeRelevantFiles({ graphFiles = [], heuristicFiles = [], limit = 3 } = {}) {
|
|
128
|
+
const byPath = new Map();
|
|
129
|
+
for (const file of heuristicFiles) {
|
|
130
|
+
if (!file?.path) continue;
|
|
131
|
+
byPath.set(file.path, {
|
|
132
|
+
...file,
|
|
133
|
+
source: file.source || "heuristic",
|
|
134
|
+
reasons: [...new Set(file.reasons || [])]
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const file of graphFiles) {
|
|
139
|
+
if (!file?.path) continue;
|
|
140
|
+
const existing = byPath.get(file.path);
|
|
141
|
+
const reasons = [...new Set([...(file.reasons || []), ...(existing?.reasons || [])])];
|
|
142
|
+
byPath.set(file.path, {
|
|
143
|
+
...existing,
|
|
144
|
+
...file,
|
|
145
|
+
source: "graph",
|
|
146
|
+
score: 100 + Number(file.score || 0) * 10 + Number(existing?.score || 0),
|
|
147
|
+
reasons
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return [...byPath.values()]
|
|
152
|
+
.sort((a, b) => Number(b.score || 0) - Number(a.score || 0) || a.path.localeCompare(b.path))
|
|
153
|
+
.slice(0, limit);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function mergeGraphResults({ cwd, results, limit }) {
|
|
157
|
+
const byPath = new Map();
|
|
158
|
+
for (const result of Array.isArray(results) ? results : []) {
|
|
159
|
+
const normalized = normalizeRepoPath(cwd, result.path);
|
|
160
|
+
if (!normalized) continue;
|
|
161
|
+
|
|
162
|
+
const existing = byPath.get(normalized) || {
|
|
163
|
+
path: normalized,
|
|
164
|
+
score: 0,
|
|
165
|
+
source: "graph",
|
|
166
|
+
reasons: []
|
|
167
|
+
};
|
|
168
|
+
existing.score += 1;
|
|
169
|
+
existing.reasons.push(`graph:${result.query}`);
|
|
170
|
+
byPath.set(normalized, existing);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return [...byPath.values()]
|
|
174
|
+
.map((file) => ({ ...file, reasons: [...new Set(file.reasons)] }))
|
|
175
|
+
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
|
|
176
|
+
.slice(0, limit);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeRepoPath(cwd, filePath) {
|
|
180
|
+
const normalized = path.normalize(String(filePath || ""));
|
|
181
|
+
if (!normalized) return null;
|
|
182
|
+
if (path.isAbsolute(normalized)) {
|
|
183
|
+
const relative = path.relative(cwd, normalized);
|
|
184
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
|
|
185
|
+
return relative;
|
|
186
|
+
}
|
|
187
|
+
return normalized.startsWith("..") ? null : normalized;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function addQuery(queries, value) {
|
|
191
|
+
const query = String(value || "").trim();
|
|
192
|
+
if (query.length < 3) return;
|
|
193
|
+
if (queries.some((existing) => existing.toLowerCase() === query.toLowerCase())) return;
|
|
194
|
+
queries.push(query);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function extractIdentifiers(value) {
|
|
198
|
+
const identifiers = [];
|
|
199
|
+
const text = String(value || "");
|
|
200
|
+
const patterns = [
|
|
201
|
+
/`([^`]+)`/g,
|
|
202
|
+
/\b[A-Z][A-Za-z0-9]*(?:[A-Z][A-Za-z0-9]*)+\b/g,
|
|
203
|
+
/\b[a-z0-9]+(?:[-_.][a-z0-9]+)+\b/gi
|
|
204
|
+
];
|
|
205
|
+
for (const pattern of patterns) {
|
|
206
|
+
for (const match of text.matchAll(pattern)) identifiers.push(match[1] || match[0]);
|
|
207
|
+
}
|
|
208
|
+
return identifiers;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function extractTerms(value) {
|
|
212
|
+
return String(value || "")
|
|
213
|
+
.toLowerCase()
|
|
214
|
+
.normalize("NFD")
|
|
215
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
216
|
+
.split(/[^a-z0-9]+/g)
|
|
217
|
+
.filter((term) => term.length > 2 && !QUERY_STOP_WORDS.has(term));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function importantPhrases(terms) {
|
|
221
|
+
const phrases = [];
|
|
222
|
+
for (let index = 0; index < terms.length - 1; index += 1) {
|
|
223
|
+
phrases.push(`${terms[index]} ${terms[index + 1]}`);
|
|
224
|
+
}
|
|
225
|
+
return phrases.slice(0, 4);
|
|
226
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeJsonFile } from "./fs-utils.js";
|
|
4
|
+
|
|
5
|
+
function codexHome() {
|
|
6
|
+
return process.env.CODEX_HOME || path.join(process.env.HOME || process.cwd(), ".codex");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function readStdinJson() {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
12
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
13
|
+
if (!raw) return {};
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function writeJson(value) {
|
|
18
|
+
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function pluginDataDir(fileName = "") {
|
|
22
|
+
const root = process.env.PLUGIN_DATA || path.join(codexHome(), "contextos");
|
|
23
|
+
try {
|
|
24
|
+
fs.mkdirSync(root, { recursive: true });
|
|
25
|
+
} catch {
|
|
26
|
+
return path.join(process.cwd(), ".contextos", fileName);
|
|
27
|
+
}
|
|
28
|
+
return path.join(root, fileName);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function logDebug(event, payload) {
|
|
32
|
+
const line = JSON.stringify({ at: new Date().toISOString(), event, payload });
|
|
33
|
+
try {
|
|
34
|
+
fs.appendFileSync(pluginDataDir("debug.log"), `${line}\n`, "utf8");
|
|
35
|
+
} catch {
|
|
36
|
+
// Logging must never break Codex hooks.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function logError(event, error) {
|
|
41
|
+
const line = JSON.stringify({
|
|
42
|
+
at: new Date().toISOString(),
|
|
43
|
+
event,
|
|
44
|
+
message: error?.message || String(error),
|
|
45
|
+
stack: error?.stack
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
fs.appendFileSync(pluginDataDir("error.log"), `${line}\n`, "utf8");
|
|
49
|
+
} catch {
|
|
50
|
+
// failOpen depends on this staying best-effort.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function failOpen(event, error, fallback) {
|
|
55
|
+
logError(event, error);
|
|
56
|
+
writeJson(fallback);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function persistRuntime(name, value) {
|
|
60
|
+
try {
|
|
61
|
+
writeJsonFile(pluginDataDir(name), value);
|
|
62
|
+
} catch {
|
|
63
|
+
// Runtime persistence is diagnostic; hook output is the critical path.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const JS_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
|
5
|
+
const IMPORT_RE = /\bimport\s+(?:[^'"]+\s+from\s+)?['"]([^'"]+)['"]|\brequire\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
6
|
+
|
|
7
|
+
export function expandImportGraph({ cwd = process.cwd(), seedFiles = [], limit = 6 } = {}) {
|
|
8
|
+
const seeds = new Set(seedFiles.map((file) => normalizeRel(file.path)).filter(Boolean));
|
|
9
|
+
if (!seeds.size) return [];
|
|
10
|
+
|
|
11
|
+
const files = [];
|
|
12
|
+
for (const root of importGraphRoots(cwd, [...seeds])) {
|
|
13
|
+
walkSourceFiles(root, (filePath) => files.push(path.relative(cwd, filePath)));
|
|
14
|
+
}
|
|
15
|
+
const fileSet = new Set(files);
|
|
16
|
+
const outgoing = new Map();
|
|
17
|
+
const incoming = new Map();
|
|
18
|
+
|
|
19
|
+
for (const rel of files) {
|
|
20
|
+
const imports = resolveImports({ cwd, rel, fileSet });
|
|
21
|
+
outgoing.set(rel, imports);
|
|
22
|
+
for (const target of imports) {
|
|
23
|
+
const importers = incoming.get(target) || new Set();
|
|
24
|
+
importers.add(rel);
|
|
25
|
+
incoming.set(target, importers);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const candidates = new Map();
|
|
30
|
+
for (const seed of seeds) {
|
|
31
|
+
for (const target of outgoing.get(seed) || []) {
|
|
32
|
+
addImportCandidate(candidates, target, `imports:${seed}`, 4);
|
|
33
|
+
}
|
|
34
|
+
for (const importer of incoming.get(seed) || []) {
|
|
35
|
+
addImportCandidate(candidates, importer, `imported-by:${seed}`, 5);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return [...candidates.values()]
|
|
40
|
+
.filter((file) => !seeds.has(file.path))
|
|
41
|
+
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
|
|
42
|
+
.slice(0, limit);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function importGraphRoots(cwd, seedFiles) {
|
|
46
|
+
const roots = new Set();
|
|
47
|
+
for (const file of seedFiles) {
|
|
48
|
+
const parts = file.split(path.sep);
|
|
49
|
+
const rootParts = parts[0] === "services" && parts[1] ? parts.slice(0, 2) : parts.slice(0, 1);
|
|
50
|
+
const root = path.join(cwd, ...rootParts);
|
|
51
|
+
if (fs.existsSync(root)) roots.add(root);
|
|
52
|
+
}
|
|
53
|
+
return roots.size ? [...roots] : [cwd];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function addImportCandidate(candidates, filePath, reason, score) {
|
|
57
|
+
const existing = candidates.get(filePath) || {
|
|
58
|
+
path: filePath,
|
|
59
|
+
score: 0,
|
|
60
|
+
source: "import-graph",
|
|
61
|
+
reasons: []
|
|
62
|
+
};
|
|
63
|
+
existing.score += score;
|
|
64
|
+
existing.reasons.push(reason);
|
|
65
|
+
candidates.set(filePath, {
|
|
66
|
+
...existing,
|
|
67
|
+
reasons: [...new Set(existing.reasons)]
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolveImports({ cwd, rel, fileSet }) {
|
|
72
|
+
const fullPath = path.join(cwd, rel);
|
|
73
|
+
let content = "";
|
|
74
|
+
try {
|
|
75
|
+
content = fs.readFileSync(fullPath, "utf8");
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const resolved = new Set();
|
|
81
|
+
for (const match of content.matchAll(IMPORT_RE)) {
|
|
82
|
+
const specifier = match[1] || match[2];
|
|
83
|
+
if (!specifier?.startsWith(".")) continue;
|
|
84
|
+
const target = resolveRelativeImport(path.dirname(rel), specifier, fileSet);
|
|
85
|
+
if (target) resolved.add(target);
|
|
86
|
+
}
|
|
87
|
+
return [...resolved];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveRelativeImport(fromDir, specifier, fileSet) {
|
|
91
|
+
const base = normalizeRel(path.join(fromDir, specifier));
|
|
92
|
+
const candidates = [
|
|
93
|
+
base,
|
|
94
|
+
...JS_EXTENSIONS.map((ext) => `${base}${ext}`),
|
|
95
|
+
...JS_EXTENSIONS.map((ext) => path.join(base, `index${ext}`))
|
|
96
|
+
];
|
|
97
|
+
return candidates.find((candidate) => fileSet.has(candidate)) || null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeRel(filePath) {
|
|
101
|
+
const normalized = path.normalize(String(filePath || ""));
|
|
102
|
+
if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) return null;
|
|
103
|
+
return normalized;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function walkSourceFiles(directory, onFile, depth = 0) {
|
|
107
|
+
if (depth > 8) return;
|
|
108
|
+
let entries = [];
|
|
109
|
+
try {
|
|
110
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
111
|
+
} catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (entry.name.startsWith(".") || ["node_modules", "dist", "build", "coverage", ".next"].includes(entry.name)) continue;
|
|
117
|
+
const fullPath = path.join(directory, entry.name);
|
|
118
|
+
if (entry.isDirectory()) {
|
|
119
|
+
walkSourceFiles(fullPath, onFile, depth + 1);
|
|
120
|
+
} else if (entry.isFile() && JS_EXTENSIONS.includes(path.extname(entry.name))) {
|
|
121
|
+
onFile(fullPath);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|