@sean.holung/minicode 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/LICENSE +201 -0
- package/README.md +241 -0
- package/dist/src/agent/agent.js +209 -0
- package/dist/src/agent/config.js +151 -0
- package/dist/src/agent/types.js +1 -0
- package/dist/src/index.js +138 -0
- package/dist/src/indexer/cache.js +121 -0
- package/dist/src/indexer/code-map.js +92 -0
- package/dist/src/indexer/plugin-loader.js +78 -0
- package/dist/src/indexer/plugins/typescript.js +327 -0
- package/dist/src/indexer/project-index.js +145 -0
- package/dist/src/indexer/types.js +1 -0
- package/dist/src/model/client.js +374 -0
- package/dist/src/prompt/system-prompt.js +91 -0
- package/dist/src/safety/guardrails.js +55 -0
- package/dist/src/session/session.js +95 -0
- package/dist/src/tools/edit-file.js +73 -0
- package/dist/src/tools/find-references.js +52 -0
- package/dist/src/tools/get-dependencies.js +56 -0
- package/dist/src/tools/helpers.js +42 -0
- package/dist/src/tools/list-files.js +63 -0
- package/dist/src/tools/read-file.js +79 -0
- package/dist/src/tools/read-symbol.js +96 -0
- package/dist/src/tools/registry.js +68 -0
- package/dist/src/tools/run-command.js +92 -0
- package/dist/src/tools/search-code-map.js +72 -0
- package/dist/src/tools/search.js +153 -0
- package/dist/src/tools/write-file.js +44 -0
- package/dist/src/ui/app.js +31 -0
- package/dist/src/ui/cli-ink.js +168 -0
- package/dist/src/ui/components/activity-pane.js +35 -0
- package/dist/src/ui/components/header-bar.js +6 -0
- package/dist/src/ui/components/input-composer.js +46 -0
- package/dist/src/ui/components/tool-timeline-item.js +37 -0
- package/dist/src/ui/events.js +1 -0
- package/dist/src/ui/state/ui-store.js +89 -0
- package/dist/src/ui/theme.js +23 -0
- package/dist/tests/agent.test.js +130 -0
- package/dist/tests/cache.test.js +37 -0
- package/dist/tests/config.test.js +37 -0
- package/dist/tests/dependency-graph.test.js +27 -0
- package/dist/tests/file-tools.test.js +73 -0
- package/dist/tests/find-references.test.js +30 -0
- package/dist/tests/get-dependencies.test.js +35 -0
- package/dist/tests/guardrails.test.js +18 -0
- package/dist/tests/indexer.test.js +201 -0
- package/dist/tests/model-client-openai.test.js +84 -0
- package/dist/tests/read-symbol.test.js +83 -0
- package/dist/tests/search-code-map.test.js +30 -0
- package/dist/tests/session.test.js +37 -0
- package/dist/tests/system-prompt.test.js +82 -0
- package/dist/tests/test-utils.js +18 -0
- package/dist/tests/tool-registry.test.js +41 -0
- package/package.json +43 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { access, readFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import dotenv from "dotenv";
|
|
7
|
+
/** User-level config directory: ~/.minicode */
|
|
8
|
+
export const MINICODE_HOME = path.join(os.homedir(), ".minicode");
|
|
9
|
+
/**
|
|
10
|
+
* Format the current agent configuration for display (e.g. /config slash command).
|
|
11
|
+
*/
|
|
12
|
+
export function formatConfigForDisplay(config) {
|
|
13
|
+
const lines = [
|
|
14
|
+
"configHome: " + MINICODE_HOME + " (.env, agent.config.json)",
|
|
15
|
+
"workspaceRoot: " + config.workspaceRoot,
|
|
16
|
+
"modelProvider: " + config.modelProvider,
|
|
17
|
+
"model: " + config.model,
|
|
18
|
+
"maxSteps: " + config.maxSteps,
|
|
19
|
+
"maxTokens: " + config.maxTokens,
|
|
20
|
+
"maxContextTokens: " + config.maxContextTokens,
|
|
21
|
+
"commandTimeoutMs: " + config.commandTimeoutMs,
|
|
22
|
+
"maxFileSizeBytes: " + config.maxFileSizeBytes,
|
|
23
|
+
"maxToolOutputChars: " + config.maxToolOutputChars,
|
|
24
|
+
"keepRecentMessages: " + config.keepRecentMessages,
|
|
25
|
+
"loopDetectionWindow: " + config.loopDetectionWindow,
|
|
26
|
+
"confirmDestructive: " + config.confirmDestructive,
|
|
27
|
+
"commandDenylist: " + config.commandDenylist.length + " patterns",
|
|
28
|
+
"openAiBaseUrl: " + config.openAiBaseUrl,
|
|
29
|
+
"openAiApiKey: " + (config.openAiApiKey ? "***" : "(unset)"),
|
|
30
|
+
];
|
|
31
|
+
return lines.join("\n");
|
|
32
|
+
}
|
|
33
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const envPath = __dirname.includes(`${path.sep}dist${path.sep}`)
|
|
35
|
+
? path.resolve(__dirname, "../../../.env")
|
|
36
|
+
: path.resolve(__dirname, "../../.env");
|
|
37
|
+
// Load order: user home (~/.minicode/.env) < project .env < cwd .env
|
|
38
|
+
dotenv.config({ path: path.join(MINICODE_HOME, ".env") });
|
|
39
|
+
dotenv.config({ path: envPath, override: true });
|
|
40
|
+
dotenv.config({ path: path.resolve(process.cwd(), ".env"), override: true });
|
|
41
|
+
const DEFAULT_COMMAND_DENYLIST = [
|
|
42
|
+
/\brm\s+-rf\s+\//i,
|
|
43
|
+
/\bmkfs\b/i,
|
|
44
|
+
/\bdd\s+if=/i,
|
|
45
|
+
/:\(\)\s*\{\s*:\|:&\s*\};:/,
|
|
46
|
+
/\bshutdown\b/i,
|
|
47
|
+
/\breboot\b/i,
|
|
48
|
+
/\bpoweroff\b/i,
|
|
49
|
+
/\binit\s+0\b/i,
|
|
50
|
+
/\bchmod\s+-R\s+777\s+\//i,
|
|
51
|
+
];
|
|
52
|
+
function parseNumber(value, fallback) {
|
|
53
|
+
if (!value) {
|
|
54
|
+
return fallback;
|
|
55
|
+
}
|
|
56
|
+
const parsed = Number(value);
|
|
57
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
58
|
+
}
|
|
59
|
+
function parseBoolean(value, fallback) {
|
|
60
|
+
if (value === undefined) {
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
const normalized = value.trim().toLowerCase();
|
|
64
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return fallback;
|
|
71
|
+
}
|
|
72
|
+
async function loadConfigFile(configPath) {
|
|
73
|
+
try {
|
|
74
|
+
await access(configPath);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
const file = await readFile(configPath, "utf8");
|
|
80
|
+
const parsed = JSON.parse(file);
|
|
81
|
+
if (!parsed || typeof parsed !== "object") {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
return parsed;
|
|
85
|
+
}
|
|
86
|
+
function parseUserDenylist(patterns) {
|
|
87
|
+
if (!patterns?.length) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const denylist = [];
|
|
91
|
+
for (const pattern of patterns) {
|
|
92
|
+
try {
|
|
93
|
+
denylist.push(new RegExp(pattern, "i"));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Ignore malformed denylist patterns from user config.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return denylist;
|
|
100
|
+
}
|
|
101
|
+
function parseModelProvider(value) {
|
|
102
|
+
const normalized = value?.trim().toLowerCase();
|
|
103
|
+
if (normalized === "openai-compatible" ||
|
|
104
|
+
normalized === "openai" ||
|
|
105
|
+
normalized === "lmstudio" ||
|
|
106
|
+
normalized === "lm-studio") {
|
|
107
|
+
return "openai-compatible";
|
|
108
|
+
}
|
|
109
|
+
return "anthropic";
|
|
110
|
+
}
|
|
111
|
+
export async function loadAgentConfig(cwd = process.cwd()) {
|
|
112
|
+
const homeConfigPath = path.join(MINICODE_HOME, "agent.config.json");
|
|
113
|
+
const workspaceConfigPath = path.resolve(cwd, "agent.config.json");
|
|
114
|
+
const homeConfig = await loadConfigFile(homeConfigPath);
|
|
115
|
+
const workspaceConfig = await loadConfigFile(workspaceConfigPath);
|
|
116
|
+
const fileConfig = { ...homeConfig, ...workspaceConfig };
|
|
117
|
+
const rawWorkspaceRoot = process.env.WORKSPACE_ROOT ?? fileConfig.workspaceRoot ?? cwd;
|
|
118
|
+
const workspaceRoot = path.resolve(cwd, rawWorkspaceRoot);
|
|
119
|
+
const commandDenylist = [
|
|
120
|
+
...DEFAULT_COMMAND_DENYLIST,
|
|
121
|
+
...parseUserDenylist(fileConfig.commandDenylist),
|
|
122
|
+
];
|
|
123
|
+
const rawBaseUrl = process.env.OPENAI_BASE_URL ??
|
|
124
|
+
fileConfig.openAiBaseUrl ??
|
|
125
|
+
"http://localhost:1234/v1";
|
|
126
|
+
const isOpenRouter = rawBaseUrl.includes("openrouter");
|
|
127
|
+
const openAiApiKey = isOpenRouter
|
|
128
|
+
? (process.env.OPENROUTER_API_KEY ??
|
|
129
|
+
process.env.OPENAI_API_KEY ??
|
|
130
|
+
fileConfig.openAiApiKey)
|
|
131
|
+
: (process.env.OPENAI_API_KEY ?? fileConfig.openAiApiKey);
|
|
132
|
+
return {
|
|
133
|
+
modelProvider: parseModelProvider(process.env.MODEL_PROVIDER ?? fileConfig.modelProvider ?? "openai-compatible"),
|
|
134
|
+
model: process.env.MODEL ??
|
|
135
|
+
fileConfig.model ??
|
|
136
|
+
"zai-org/glm-4.7-flash",
|
|
137
|
+
maxSteps: parseNumber(process.env.MAX_STEPS, fileConfig.maxSteps ?? 50),
|
|
138
|
+
maxTokens: parseNumber(process.env.MAX_TOKENS, fileConfig.maxTokens ?? 4096),
|
|
139
|
+
maxContextTokens: parseNumber(process.env.MAX_CONTEXT_TOKENS, fileConfig.maxContextTokens ?? 120_000),
|
|
140
|
+
workspaceRoot,
|
|
141
|
+
commandTimeoutMs: parseNumber(process.env.COMMAND_TIMEOUT_MS, fileConfig.commandTimeout ?? 30_000),
|
|
142
|
+
maxFileSizeBytes: parseNumber(process.env.MAX_FILE_SIZE_BYTES, fileConfig.maxFileSizeBytes ?? 1_000_000),
|
|
143
|
+
commandDenylist,
|
|
144
|
+
confirmDestructive: parseBoolean(process.env.CONFIRM_DESTRUCTIVE, fileConfig.confirmDestructive ?? false),
|
|
145
|
+
keepRecentMessages: parseNumber(process.env.KEEP_RECENT_MESSAGES, fileConfig.keepRecentMessages ?? 12),
|
|
146
|
+
loopDetectionWindow: parseNumber(process.env.LOOP_DETECTION_WINDOW, fileConfig.loopDetectionWindow ?? 6),
|
|
147
|
+
maxToolOutputChars: parseNumber(process.env.MAX_TOOL_OUTPUT_CHARS, fileConfig.maxToolOutputChars ?? 15_000),
|
|
148
|
+
openAiBaseUrl: rawBaseUrl,
|
|
149
|
+
...(openAiApiKey !== undefined ? { openAiApiKey } : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { CodingAgent } from "./agent/agent.js";
|
|
5
|
+
import { formatConfigForDisplay, loadAgentConfig } from "./agent/config.js";
|
|
6
|
+
import { computeFileHashes, getWorkspaceCacheDir, loadIndex, saveIndex, } from "./indexer/cache.js";
|
|
7
|
+
import { buildProjectIndex } from "./indexer/project-index.js";
|
|
8
|
+
import { createModelClient } from "./model/client.js";
|
|
9
|
+
import { ToolRegistry } from "./tools/registry.js";
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const args = argv.slice(2);
|
|
12
|
+
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
13
|
+
const filtered = args.filter((a) => a !== "--verbose" && a !== "-v");
|
|
14
|
+
const task = filtered.join(" ").trim();
|
|
15
|
+
return { verbose, task };
|
|
16
|
+
}
|
|
17
|
+
function printBanner() {
|
|
18
|
+
console.log("minicode");
|
|
19
|
+
console.log('Type your request, or "/exit" to quit.');
|
|
20
|
+
}
|
|
21
|
+
async function runInteractive(verbose, initialTask) {
|
|
22
|
+
const config = await loadAgentConfig();
|
|
23
|
+
const modelClient = createModelClient(config);
|
|
24
|
+
let projectIndex;
|
|
25
|
+
try {
|
|
26
|
+
const cacheDir = getWorkspaceCacheDir(config.workspaceRoot);
|
|
27
|
+
const fileHashes = await computeFileHashes(config.workspaceRoot);
|
|
28
|
+
const cached = await loadIndex(cacheDir, fileHashes);
|
|
29
|
+
if (cached) {
|
|
30
|
+
projectIndex = cached;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
projectIndex = await buildProjectIndex(config.workspaceRoot);
|
|
34
|
+
await saveIndex(projectIndex, cacheDir, fileHashes);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
projectIndex = undefined;
|
|
39
|
+
}
|
|
40
|
+
const toolRegistry = ToolRegistry.createDefault(config, projectIndex);
|
|
41
|
+
const agent = new CodingAgent({
|
|
42
|
+
config,
|
|
43
|
+
modelClient,
|
|
44
|
+
toolRegistry,
|
|
45
|
+
verbose,
|
|
46
|
+
...(projectIndex !== undefined ? { projectIndex } : {}),
|
|
47
|
+
onProgress: (msg) => console.error(` ${msg}`),
|
|
48
|
+
});
|
|
49
|
+
printBanner();
|
|
50
|
+
console.log(`Workspace: ${config.workspaceRoot}`);
|
|
51
|
+
console.log(`Provider: ${config.modelProvider}`);
|
|
52
|
+
console.log(`Model: ${config.model}`);
|
|
53
|
+
if (verbose) {
|
|
54
|
+
console.log("Verbose: enabled");
|
|
55
|
+
}
|
|
56
|
+
const rl = createInterface({
|
|
57
|
+
input: process.stdin,
|
|
58
|
+
output: process.stdout,
|
|
59
|
+
});
|
|
60
|
+
let shuttingDown = false;
|
|
61
|
+
let turnAbortController = null;
|
|
62
|
+
process.on("SIGINT", () => {
|
|
63
|
+
if (turnAbortController) {
|
|
64
|
+
turnAbortController.abort();
|
|
65
|
+
}
|
|
66
|
+
else if (shuttingDown) {
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
shuttingDown = true;
|
|
71
|
+
console.log("\nReceived interrupt. Exiting gracefully.");
|
|
72
|
+
rl.close();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
let pendingInput = initialTask ?? null;
|
|
76
|
+
while (!shuttingDown) {
|
|
77
|
+
const input = pendingInput !== null
|
|
78
|
+
? pendingInput
|
|
79
|
+
: await rl.question("\nYou> ");
|
|
80
|
+
if (pendingInput !== null) {
|
|
81
|
+
console.log(`\nYou> ${input}`);
|
|
82
|
+
}
|
|
83
|
+
pendingInput = null;
|
|
84
|
+
const trimmed = input.trim();
|
|
85
|
+
if (trimmed.length === 0) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (trimmed === "/exit" || trimmed === "exit" || trimmed === "quit") {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
if (trimmed === "/help") {
|
|
92
|
+
console.log('Commands: "/help", "/config", "/exit"');
|
|
93
|
+
console.log("Start with --verbose or -v to log prompts, responses, and tool calls.");
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (trimmed === "/config") {
|
|
97
|
+
console.log("\n" + formatConfigForDisplay(config) + "\n");
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
turnAbortController = new AbortController();
|
|
101
|
+
try {
|
|
102
|
+
const { text, usage } = await agent.runTurn(trimmed, {
|
|
103
|
+
signal: turnAbortController.signal,
|
|
104
|
+
});
|
|
105
|
+
console.log(`\nAgent> ${text}`);
|
|
106
|
+
if (usage) {
|
|
107
|
+
console.error(` tokens: ${usage.inputTokens} in, ${usage.outputTokens} out`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const message = error instanceof Error && error.name === "AbortError"
|
|
112
|
+
? "Cancelled"
|
|
113
|
+
: error instanceof Error
|
|
114
|
+
? error.message
|
|
115
|
+
: "Unknown runtime failure";
|
|
116
|
+
console.error(`\nAgent error: ${message}`);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
turnAbortController = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
rl.close();
|
|
123
|
+
}
|
|
124
|
+
async function main() {
|
|
125
|
+
const { verbose, task } = parseArgs(process.argv);
|
|
126
|
+
const uiMode = process.env.CLI_UI_MODE ?? "ink";
|
|
127
|
+
if (uiMode !== "legacy" && process.stdin.isTTY) {
|
|
128
|
+
const { runInkCli } = await import("./ui/cli-ink.js");
|
|
129
|
+
await runInkCli(verbose, task.length > 0 ? task : undefined);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
await runInteractive(verbose, task.length > 0 ? task : undefined);
|
|
133
|
+
}
|
|
134
|
+
main().catch((error) => {
|
|
135
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
136
|
+
console.error(`Fatal error: ${message}`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { MINICODE_HOME } from "../agent/config.js";
|
|
5
|
+
import { createProjectIndex } from "./project-index.js";
|
|
6
|
+
import { loadPlugins } from "./plugin-loader.js";
|
|
7
|
+
const CACHE_FILENAME = "index.json";
|
|
8
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage"]);
|
|
9
|
+
function hashWorkspacePath(workspaceRoot) {
|
|
10
|
+
const normalized = path.resolve(workspaceRoot);
|
|
11
|
+
return createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 32);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Return the cache directory for a workspace. Index files live under ~/.minicode/cache/<hash>/
|
|
15
|
+
* so caches are global and keyed by workspace path.
|
|
16
|
+
*/
|
|
17
|
+
export function getWorkspaceCacheDir(workspaceRoot) {
|
|
18
|
+
return path.join(MINICODE_HOME, "cache", hashWorkspacePath(workspaceRoot));
|
|
19
|
+
}
|
|
20
|
+
function hashContent(content) {
|
|
21
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
22
|
+
}
|
|
23
|
+
async function collectSourceFiles(dir, root, files) {
|
|
24
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const fullPath = path.join(dir, entry.name);
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
|
|
29
|
+
await collectSourceFiles(fullPath, root, files);
|
|
30
|
+
}
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (entry.isFile()) {
|
|
34
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
35
|
+
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext)) {
|
|
36
|
+
files.push(path.relative(root, fullPath));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Compute content hashes for all source files in the workspace.
|
|
43
|
+
*/
|
|
44
|
+
export async function computeFileHashes(workspaceRoot) {
|
|
45
|
+
const root = path.resolve(workspaceRoot);
|
|
46
|
+
const sourceFiles = [];
|
|
47
|
+
await collectSourceFiles(root, root, sourceFiles);
|
|
48
|
+
const hashes = new Map();
|
|
49
|
+
for (const relPath of sourceFiles) {
|
|
50
|
+
const absPath = path.join(root, relPath);
|
|
51
|
+
try {
|
|
52
|
+
const content = await readFile(absPath, "utf8");
|
|
53
|
+
hashes.set(relPath, hashContent(content));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// skip unreadable files
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return hashes;
|
|
60
|
+
}
|
|
61
|
+
function hashesMatch(cached, current) {
|
|
62
|
+
if (Object.keys(cached).length !== current.size)
|
|
63
|
+
return false;
|
|
64
|
+
for (const [relPath, hash] of current) {
|
|
65
|
+
if (cached[relPath] !== hash)
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Save the project index to disk. Call after buildProjectIndex.
|
|
72
|
+
*/
|
|
73
|
+
export async function saveIndex(index, cacheDir, fileHashes) {
|
|
74
|
+
const payload = {
|
|
75
|
+
version: 1,
|
|
76
|
+
fileHashes: Object.fromEntries(fileHashes),
|
|
77
|
+
symbols: [...index.symbols.entries()],
|
|
78
|
+
files: [...index.files.entries()],
|
|
79
|
+
dependencyEdges: [...index.dependencyEdges],
|
|
80
|
+
projectFiles: [...index.projectFiles.entries()],
|
|
81
|
+
workspaceRoot: index.workspaceRoot,
|
|
82
|
+
};
|
|
83
|
+
await mkdir(cacheDir, { recursive: true });
|
|
84
|
+
const cachePath = path.join(cacheDir, CACHE_FILENAME);
|
|
85
|
+
await writeFile(cachePath, JSON.stringify(payload, null, 0), "utf8");
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Load the project index from cache if all file hashes match.
|
|
89
|
+
* Returns null if cache is missing, invalid, or any file has changed.
|
|
90
|
+
*/
|
|
91
|
+
export async function loadIndex(cacheDir, fileHashes) {
|
|
92
|
+
const cachePath = path.join(cacheDir, CACHE_FILENAME);
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = await readFile(cachePath, "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
let payload;
|
|
101
|
+
try {
|
|
102
|
+
payload = JSON.parse(raw);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (payload.version !== 1 || !payload.fileHashes)
|
|
108
|
+
return null;
|
|
109
|
+
if (!hashesMatch(payload.fileHashes, fileHashes))
|
|
110
|
+
return null;
|
|
111
|
+
const symbols = new Map(payload.symbols);
|
|
112
|
+
const files = new Map(payload.files);
|
|
113
|
+
const dependencyEdges = payload.dependencyEdges ?? [];
|
|
114
|
+
const projectFiles = new Map(payload.projectFiles);
|
|
115
|
+
const workspaceRoot = payload.workspaceRoot ?? "";
|
|
116
|
+
const plugins = await loadPlugins(workspaceRoot);
|
|
117
|
+
return createProjectIndexFromCache(symbols, files, dependencyEdges, plugins, projectFiles, workspaceRoot);
|
|
118
|
+
}
|
|
119
|
+
function createProjectIndexFromCache(symbols, files, dependencyEdges, plugins, projectFiles, workspaceRoot) {
|
|
120
|
+
return createProjectIndex(symbols, files, dependencyEdges, plugins, projectFiles, workspaceRoot);
|
|
121
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const DEFAULT_TOKEN_BUDGET = 1500;
|
|
2
|
+
const APPROX_CHARS_PER_TOKEN = 4;
|
|
3
|
+
function estimateTokens(text) {
|
|
4
|
+
return Math.ceil(text.length / APPROX_CHARS_PER_TOKEN);
|
|
5
|
+
}
|
|
6
|
+
function formatSymbol(symbol, indent, isMethod) {
|
|
7
|
+
if (isMethod) {
|
|
8
|
+
return `${indent} ${symbol.signature}`;
|
|
9
|
+
}
|
|
10
|
+
return `${indent}${symbol.kind} ${symbol.qualifiedName}\n${indent} ${symbol.signature}`;
|
|
11
|
+
}
|
|
12
|
+
function isEntryPointFile(filePath) {
|
|
13
|
+
return filePath === "src/index.ts" || filePath.endsWith("/index.ts");
|
|
14
|
+
}
|
|
15
|
+
function createSymbolRanker(edges) {
|
|
16
|
+
const refCount = new Map();
|
|
17
|
+
for (const e of edges) {
|
|
18
|
+
refCount.set(e.to, (refCount.get(e.to) ?? 0) + 1);
|
|
19
|
+
}
|
|
20
|
+
return (a, b) => {
|
|
21
|
+
if (a.exported !== b.exported)
|
|
22
|
+
return a.exported ? -1 : 1;
|
|
23
|
+
const refA = refCount.get(a.qualifiedName) ?? 0;
|
|
24
|
+
const refB = refCount.get(b.qualifiedName) ?? 0;
|
|
25
|
+
if (refA !== refB)
|
|
26
|
+
return refB - refA;
|
|
27
|
+
const entryA = isEntryPointFile(a.filePath) ? 1 : 0;
|
|
28
|
+
const entryB = isEntryPointFile(b.filePath) ? 1 : 0;
|
|
29
|
+
return entryB - entryA;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Generate a compact code map from symbols grouped by file.
|
|
34
|
+
* Ranks symbols by: exported > high reference count > entry points.
|
|
35
|
+
* When over budget, truncates with a footer.
|
|
36
|
+
*/
|
|
37
|
+
export function generateCodeMap(symbolsByFile, tokenBudget = DEFAULT_TOKEN_BUDGET, dependencyEdges) {
|
|
38
|
+
const totalCount = [...symbolsByFile.values()].reduce((sum, syms) => sum + syms.length, 0);
|
|
39
|
+
const lines = ["# Project Code Map", ""];
|
|
40
|
+
const rank = dependencyEdges
|
|
41
|
+
? createSymbolRanker(dependencyEdges)
|
|
42
|
+
: (a, b) => (a.exported === b.exported ? 0 : a.exported ? -1 : 1);
|
|
43
|
+
let totalTokens = estimateTokens(lines.join("\n"));
|
|
44
|
+
let truncatedSymbols = 0;
|
|
45
|
+
let shownCount = 0;
|
|
46
|
+
const filesWithTruncation = new Set();
|
|
47
|
+
const sortedFiles = [...symbolsByFile.keys()].sort();
|
|
48
|
+
for (const filePath of sortedFiles) {
|
|
49
|
+
const symbols = symbolsByFile.get(filePath);
|
|
50
|
+
if (!symbols?.length)
|
|
51
|
+
continue;
|
|
52
|
+
const sorted = [...symbols].sort(rank);
|
|
53
|
+
let currentClass = null;
|
|
54
|
+
const fileLines = [` ${filePath}`];
|
|
55
|
+
for (const symbol of sorted) {
|
|
56
|
+
const isMethod = symbol.kind === "method";
|
|
57
|
+
const indent = isMethod && currentClass ? " " : " "; // methods nest under class
|
|
58
|
+
if (symbol.kind === "class") {
|
|
59
|
+
currentClass = symbol.name;
|
|
60
|
+
}
|
|
61
|
+
else if (!isMethod) {
|
|
62
|
+
currentClass = null;
|
|
63
|
+
}
|
|
64
|
+
const block = formatSymbol(symbol, indent, isMethod);
|
|
65
|
+
const blockTokens = estimateTokens(block);
|
|
66
|
+
if (totalTokens + blockTokens > tokenBudget) {
|
|
67
|
+
truncatedSymbols += 1;
|
|
68
|
+
filesWithTruncation.add(filePath);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
fileLines.push(block);
|
|
72
|
+
totalTokens += blockTokens;
|
|
73
|
+
shownCount += 1;
|
|
74
|
+
}
|
|
75
|
+
if (fileLines.length > 1) {
|
|
76
|
+
lines.push(...fileLines, "");
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
truncatedSymbols += symbols.length;
|
|
80
|
+
filesWithTruncation.add(filePath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (truncatedSymbols > 0) {
|
|
84
|
+
const fileCount = filesWithTruncation.size;
|
|
85
|
+
lines.push(`... and ${truncatedSymbols} more symbols in ${fileCount} file${fileCount === 1 ? "" : "s"}`);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
text: lines.join("\n").trim(),
|
|
89
|
+
shownCount,
|
|
90
|
+
totalCount,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { typescriptPlugin } from "./plugins/typescript.js";
|
|
2
|
+
/**
|
|
3
|
+
* Load all available language plugins.
|
|
4
|
+
* Built-in: TypeScript.
|
|
5
|
+
* Also loads: npm packages (minicode-plugin-*), local plugins (.minicode/plugins/).
|
|
6
|
+
*/
|
|
7
|
+
export async function loadPlugins(workspaceRoot) {
|
|
8
|
+
const plugins = [];
|
|
9
|
+
plugins.push(typescriptPlugin);
|
|
10
|
+
await loadNpmPlugins(workspaceRoot, plugins);
|
|
11
|
+
await loadLocalPlugins(workspaceRoot, plugins);
|
|
12
|
+
return plugins;
|
|
13
|
+
}
|
|
14
|
+
async function loadNpmPlugins(workspaceRoot, plugins) {
|
|
15
|
+
const path = await import("node:path");
|
|
16
|
+
const { readFile } = await import("node:fs/promises");
|
|
17
|
+
const pkgPath = path.join(workspaceRoot, "package.json");
|
|
18
|
+
let pkg;
|
|
19
|
+
try {
|
|
20
|
+
const raw = await readFile(pkgPath, "utf8");
|
|
21
|
+
pkg = JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const deps = {
|
|
27
|
+
...(pkg.dependencies ?? {}),
|
|
28
|
+
...(pkg.devDependencies ?? {}),
|
|
29
|
+
};
|
|
30
|
+
const pluginPkgs = Object.keys(deps).filter((k) => k.startsWith("minicode-plugin-"));
|
|
31
|
+
for (const pkgName of pluginPkgs) {
|
|
32
|
+
try {
|
|
33
|
+
const mod = await import(pkgName);
|
|
34
|
+
const plugin = mod.default ?? mod.plugin ?? mod;
|
|
35
|
+
if (plugin && typeof plugin.canIndex === "function") {
|
|
36
|
+
plugins.push(plugin);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// skip failed plugins
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function loadLocalPlugins(workspaceRoot, plugins) {
|
|
45
|
+
const path = await import("node:path");
|
|
46
|
+
const { pathToFileURL } = await import("node:url");
|
|
47
|
+
const { readdir } = await import("node:fs/promises");
|
|
48
|
+
const pluginDir = path.join(workspaceRoot, ".minicode", "plugins");
|
|
49
|
+
let entries;
|
|
50
|
+
try {
|
|
51
|
+
entries = await readdir(pluginDir, { withFileTypes: true });
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (!entry.isFile() || !entry.name.endsWith(".js"))
|
|
58
|
+
continue;
|
|
59
|
+
const pluginPath = path.join(pluginDir, entry.name);
|
|
60
|
+
const pluginUrl = pathToFileURL(pluginPath).href;
|
|
61
|
+
try {
|
|
62
|
+
const mod = await import(pluginUrl);
|
|
63
|
+
const plugin = mod.default ?? mod.plugin ?? mod;
|
|
64
|
+
if (plugin && typeof plugin.canIndex === "function") {
|
|
65
|
+
plugins.push(plugin);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// skip failed plugins
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Return the first plugin that can index the given file path.
|
|
75
|
+
*/
|
|
76
|
+
export function getPluginForFile(filePath, plugins) {
|
|
77
|
+
return plugins.find((p) => p.canIndex(filePath));
|
|
78
|
+
}
|