@stupify/cli 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/dist/analysis.d.ts +16 -0
- package/dist/analysis.js +133 -0
- package/dist/cache.d.ts +2 -0
- package/dist/cache.js +59 -0
- package/dist/checks.d.ts +4 -0
- package/dist/checks.js +218 -0
- package/dist/command.d.ts +2 -0
- package/dist/command.js +147 -0
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +53 -0
- package/dist/counter-scout.d.ts +14 -0
- package/dist/counter-scout.js +159 -0
- package/dist/diff.d.ts +1 -0
- package/dist/diff.js +10 -0
- package/dist/doctor.d.ts +4 -0
- package/dist/doctor.js +131 -0
- package/dist/git.d.ts +11 -0
- package/dist/git.js +253 -0
- package/dist/hooks.d.ts +3 -0
- package/dist/hooks.js +117 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/model.d.ts +10 -0
- package/dist/model.js +297 -0
- package/dist/prompts.d.ts +8 -0
- package/dist/prompts.js +87 -0
- package/dist/render.d.ts +3 -0
- package/dist/render.js +93 -0
- package/dist/repomix-provider.d.ts +12 -0
- package/dist/repomix-provider.js +196 -0
- package/dist/search-bench.d.ts +1 -0
- package/dist/search-bench.js +675 -0
- package/dist/search-profile.d.ts +6 -0
- package/dist/search-profile.js +73 -0
- package/dist/sem-provider.d.ts +2 -0
- package/dist/sem-provider.js +247 -0
- package/dist/stupify.d.ts +4 -0
- package/dist/stupify.js +237 -0
- package/dist/trace.d.ts +29 -0
- package/dist/trace.js +64 -0
- package/dist/types.d.ts +320 -0
- package/dist/types.js +6 -0
- package/package.json +42 -5
- package/src/analysis.ts +188 -0
- package/src/cache.ts +65 -0
- package/src/checks.ts +221 -0
- package/src/command.ts +173 -0
- package/src/constants.ts +56 -0
- package/src/counter-scout.ts +175 -0
- package/src/diff.ts +9 -0
- package/src/doctor.ts +140 -0
- package/src/git.ts +262 -0
- package/src/hooks.ts +134 -0
- package/src/index.ts +1 -0
- package/src/model.ts +373 -0
- package/src/prompts.ts +98 -0
- package/src/render.ts +96 -0
- package/src/repomix-provider.ts +219 -0
- package/src/search-bench.ts +783 -0
- package/src/search-profile.ts +89 -0
- package/src/sem-provider.ts +282 -0
- package/src/stupify.ts +285 -0
- package/src/trace.ts +103 -0
- package/src/types.ts +340 -0
- package/bin/stupify.mjs +0 -3
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { chmod, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { gitPath, gitRoot } from "./git.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const START = "# stupify hook start";
|
|
9
|
+
const END = "# stupify hook end";
|
|
10
|
+
export async function runHookCommand(action) {
|
|
11
|
+
if (action === "status")
|
|
12
|
+
return hookStatus();
|
|
13
|
+
if (action === "install")
|
|
14
|
+
return installHook();
|
|
15
|
+
return uninstallHook();
|
|
16
|
+
}
|
|
17
|
+
export function hookSnippet() {
|
|
18
|
+
return managedBlock("stupify --staged");
|
|
19
|
+
}
|
|
20
|
+
async function hookStatus() {
|
|
21
|
+
const hookPath = await preCommitHookPath();
|
|
22
|
+
if (!existsSync(hookPath))
|
|
23
|
+
return "Stupify hook: not installed";
|
|
24
|
+
const content = await readFile(hookPath, "utf8");
|
|
25
|
+
if (hasManagedBlock(content))
|
|
26
|
+
return "Stupify hook: installed";
|
|
27
|
+
return "Stupify hook: existing non-Stupify pre-commit hook found";
|
|
28
|
+
}
|
|
29
|
+
async function installHook() {
|
|
30
|
+
const hookPath = await preCommitHookPath();
|
|
31
|
+
const block = await managedBlockForInstall();
|
|
32
|
+
if (!existsSync(hookPath)) {
|
|
33
|
+
await writeFile(hookPath, `#!/bin/sh\n${block}\n`, "utf8");
|
|
34
|
+
await chmod(hookPath, 0o755);
|
|
35
|
+
return "Stupify hook: installed";
|
|
36
|
+
}
|
|
37
|
+
const content = await readFile(hookPath, "utf8");
|
|
38
|
+
if (hasManagedBlock(content)) {
|
|
39
|
+
await writeFile(hookPath, `${replaceManagedBlock(content, block).trimEnd()}\n`, "utf8");
|
|
40
|
+
await chmod(hookPath, 0o755);
|
|
41
|
+
return "Stupify hook: updated";
|
|
42
|
+
}
|
|
43
|
+
if (isEffectivelyEmptyHook(content)) {
|
|
44
|
+
await writeFile(hookPath, `#!/bin/sh\n${block}\n`, "utf8");
|
|
45
|
+
await chmod(hookPath, 0o755);
|
|
46
|
+
return "Stupify hook: installed";
|
|
47
|
+
}
|
|
48
|
+
return `Stupify hook: existing non-Stupify pre-commit hook found; not modified.
|
|
49
|
+
Add this snippet manually if you want Stupify in that hook:
|
|
50
|
+
${block}`;
|
|
51
|
+
}
|
|
52
|
+
async function uninstallHook() {
|
|
53
|
+
const hookPath = await preCommitHookPath();
|
|
54
|
+
if (!existsSync(hookPath))
|
|
55
|
+
return "Stupify hook: not installed";
|
|
56
|
+
const content = await readFile(hookPath, "utf8");
|
|
57
|
+
if (!hasManagedBlock(content))
|
|
58
|
+
return "Stupify hook: not installed";
|
|
59
|
+
const next = replaceManagedBlock(content, "").trim();
|
|
60
|
+
if (isEffectivelyEmptyHook(next)) {
|
|
61
|
+
await rm(hookPath, { force: true });
|
|
62
|
+
return "Stupify hook: uninstalled";
|
|
63
|
+
}
|
|
64
|
+
await writeFile(hookPath, `${next}\n`, "utf8");
|
|
65
|
+
await chmod(hookPath, 0o755);
|
|
66
|
+
return "Stupify hook: uninstalled";
|
|
67
|
+
}
|
|
68
|
+
async function preCommitHookPath() {
|
|
69
|
+
const [root, hook] = await Promise.all([gitRoot(), gitPath("hooks/pre-commit")]);
|
|
70
|
+
return path.isAbsolute(hook) ? hook : path.join(root, hook);
|
|
71
|
+
}
|
|
72
|
+
function hasManagedBlock(content) {
|
|
73
|
+
return content.includes(START) && content.includes(END);
|
|
74
|
+
}
|
|
75
|
+
async function managedBlockForInstall() {
|
|
76
|
+
if (await commandExists("stupify"))
|
|
77
|
+
return managedBlock("stupify --staged");
|
|
78
|
+
const root = await gitRoot();
|
|
79
|
+
const localEntrypoint = path.join(root, "packages", "cli", "src", "stupify.ts");
|
|
80
|
+
if (existsSync(localEntrypoint) && await commandExists("bun")) {
|
|
81
|
+
return managedBlock(`bun ${shellQuote(localEntrypoint)} --staged`);
|
|
82
|
+
}
|
|
83
|
+
return managedBlock("stupify --staged");
|
|
84
|
+
}
|
|
85
|
+
function managedBlock(command) {
|
|
86
|
+
return `${START}
|
|
87
|
+
${command} || true
|
|
88
|
+
${END}`;
|
|
89
|
+
}
|
|
90
|
+
function replaceManagedBlock(content, replacement) {
|
|
91
|
+
const pattern = new RegExp(`${escapeRegExp(START)}[\\s\\S]*?${escapeRegExp(END)}`);
|
|
92
|
+
return content.replace(pattern, replacement);
|
|
93
|
+
}
|
|
94
|
+
function isEffectivelyEmptyHook(content) {
|
|
95
|
+
return content
|
|
96
|
+
.split(/\r?\n/)
|
|
97
|
+
.map((line) => line.trim())
|
|
98
|
+
.filter((line) => line && line !== "#!/bin/sh" && line !== "#!/usr/bin/env sh")
|
|
99
|
+
.length === 0;
|
|
100
|
+
}
|
|
101
|
+
function escapeRegExp(value) {
|
|
102
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
103
|
+
}
|
|
104
|
+
async function commandExists(command) {
|
|
105
|
+
try {
|
|
106
|
+
await execFileAsync("sh", ["-c", `command -v ${shellQuote(command)}`], {
|
|
107
|
+
maxBuffer: 1024 * 1024,
|
|
108
|
+
});
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function shellQuote(value) {
|
|
116
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
117
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { main } from "./stupify.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { main } from "./stupify.js";
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ModelId } from "./types.ts";
|
|
2
|
+
export type ModelProfile = "scout";
|
|
3
|
+
export type LocalModel = Readonly<{
|
|
4
|
+
id: ModelId;
|
|
5
|
+
name: string;
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
profile: ModelProfile;
|
|
8
|
+
}>;
|
|
9
|
+
export declare function firstRunModelBootstrap(modelId: ModelId): Promise<string>;
|
|
10
|
+
export declare function loadLocalModel(modelPath: string, modelId: ModelId, profile?: ModelProfile): Promise<LocalModel>;
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { homedir, platform } from "node:os";
|
|
5
|
+
import { stdin as input, stderr as statusOutput, stdout as output, } from "node:process";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { MODEL_REGISTRY } from "./constants.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const LLAMA_SERVER_HOST = "127.0.0.1";
|
|
12
|
+
export async function firstRunModelBootstrap(modelId) {
|
|
13
|
+
const selectedModel = MODEL_REGISTRY[modelId];
|
|
14
|
+
const modelDir = path.join(cacheDir(), "models");
|
|
15
|
+
const modelPath = path.join(modelDir, selectedModel.file);
|
|
16
|
+
if (await exists(modelPath))
|
|
17
|
+
return modelPath;
|
|
18
|
+
console.error(`No local Stupify model found.
|
|
19
|
+
Stupify runs locally.
|
|
20
|
+
Download this model now?
|
|
21
|
+
Model: ${selectedModel.name}
|
|
22
|
+
Size: ${selectedModel.size}`);
|
|
23
|
+
if (!(await confirm("Continue? y/N ")))
|
|
24
|
+
throw new Error("Setup cancelled.");
|
|
25
|
+
await mkdir(modelDir, { recursive: true });
|
|
26
|
+
await downloadModel(modelPath, selectedModel.url);
|
|
27
|
+
if (!(await exists(modelPath)))
|
|
28
|
+
throw new Error("Model download failed: file was not created.");
|
|
29
|
+
return modelPath;
|
|
30
|
+
}
|
|
31
|
+
export async function loadLocalModel(modelPath, modelId, profile = "scout") {
|
|
32
|
+
const selectedModel = MODEL_REGISTRY[modelId];
|
|
33
|
+
const runtime = modelRuntime(profile);
|
|
34
|
+
const runningModel = await runningServerModel(runtime.baseUrl);
|
|
35
|
+
if (runningModel) {
|
|
36
|
+
if (runningModel !== modelId)
|
|
37
|
+
await stopManagedServer(runtime);
|
|
38
|
+
if (runningModel === modelId) {
|
|
39
|
+
console.error(`Using already-loaded local ${profile} model: ${selectedModel.name}`);
|
|
40
|
+
return {
|
|
41
|
+
id: modelId,
|
|
42
|
+
name: selectedModel.name,
|
|
43
|
+
baseUrl: runtime.baseUrl,
|
|
44
|
+
profile,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
await ensureLlamaServerBinary();
|
|
49
|
+
await startLlamaServer(modelPath, modelId, selectedModel.name, runtime);
|
|
50
|
+
await waitForServer(runtime.baseUrl, modelId);
|
|
51
|
+
return {
|
|
52
|
+
id: modelId,
|
|
53
|
+
name: selectedModel.name,
|
|
54
|
+
baseUrl: runtime.baseUrl,
|
|
55
|
+
profile,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function modelRuntime(profile) {
|
|
59
|
+
const baseUrl = process.env.STUPIFY_SCOUT_LLAMA_SERVER_URL ??
|
|
60
|
+
process.env.STUPIFY_LLAMA_SERVER_URL ??
|
|
61
|
+
"http://127.0.0.1:8091";
|
|
62
|
+
return {
|
|
63
|
+
profile,
|
|
64
|
+
baseUrl,
|
|
65
|
+
port: new URL(baseUrl).port || "8091",
|
|
66
|
+
contextSize: envInteger("STUPIFY_LLAMA_CONTEXT") ?? 65_536,
|
|
67
|
+
reasoning: "off",
|
|
68
|
+
gpuLayers: envInteger("STUPIFY_LLAMA_GPU_LAYERS") ?? 999,
|
|
69
|
+
batchSize: envInteger("STUPIFY_LLAMA_BATCH") ?? 2_048,
|
|
70
|
+
ubatchSize: envInteger("STUPIFY_LLAMA_UBATCH") ?? 512,
|
|
71
|
+
parallel: envInteger("STUPIFY_LLAMA_PARALLEL") ?? 2,
|
|
72
|
+
threads: envInteger("STUPIFY_LLAMA_THREADS"),
|
|
73
|
+
threadsBatch: envInteger("STUPIFY_LLAMA_THREADS_BATCH"),
|
|
74
|
+
flashAttention: envBoolean("STUPIFY_LLAMA_FLASH_ATTN"),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function runningServerModel(baseUrl) {
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetch(`${baseUrl}/v1/models`, {
|
|
80
|
+
signal: AbortSignal.timeout(500),
|
|
81
|
+
});
|
|
82
|
+
if (!response.ok)
|
|
83
|
+
return null;
|
|
84
|
+
const body = (await response.json());
|
|
85
|
+
const id = body.data?.[0]?.id;
|
|
86
|
+
return typeof id === "string" ? id : null;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function ensureLlamaServerBinary() {
|
|
93
|
+
try {
|
|
94
|
+
await execFileAsync("llama-server", ["--version"], {
|
|
95
|
+
maxBuffer: 1024 * 1024,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new Error(`Stupify needs llama-server for local inference.
|
|
100
|
+
Install llama.cpp first:
|
|
101
|
+
brew install llama.cpp`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function startLlamaServer(modelPath, modelId, modelName, runtime) {
|
|
105
|
+
const logDir = path.join(cacheDir(), "logs");
|
|
106
|
+
await mkdir(logDir, { recursive: true });
|
|
107
|
+
const logPath = path.join(logDir, "llama-server.log");
|
|
108
|
+
const out = await open(logPath, "a");
|
|
109
|
+
const err = await open(logPath, "a");
|
|
110
|
+
console.error(`Starting local ${runtime.profile} model server: ${modelName}`);
|
|
111
|
+
console.error(`llama-server log: ${logPath}`);
|
|
112
|
+
const args = [
|
|
113
|
+
"-m",
|
|
114
|
+
modelPath,
|
|
115
|
+
"-a",
|
|
116
|
+
modelId,
|
|
117
|
+
"--host",
|
|
118
|
+
LLAMA_SERVER_HOST,
|
|
119
|
+
"--port",
|
|
120
|
+
runtime.port,
|
|
121
|
+
"-c",
|
|
122
|
+
String(runtime.contextSize),
|
|
123
|
+
"--reasoning",
|
|
124
|
+
runtime.reasoning,
|
|
125
|
+
"--no-warmup",
|
|
126
|
+
];
|
|
127
|
+
if (runtime.gpuLayers !== undefined)
|
|
128
|
+
args.push("-ngl", String(runtime.gpuLayers));
|
|
129
|
+
if (runtime.batchSize !== undefined)
|
|
130
|
+
args.push("-b", String(runtime.batchSize));
|
|
131
|
+
if (runtime.ubatchSize !== undefined)
|
|
132
|
+
args.push("-ub", String(runtime.ubatchSize));
|
|
133
|
+
if (runtime.parallel !== undefined)
|
|
134
|
+
args.push("-np", String(runtime.parallel));
|
|
135
|
+
if (runtime.threads !== undefined)
|
|
136
|
+
args.push("-t", String(runtime.threads));
|
|
137
|
+
if (runtime.threadsBatch !== undefined)
|
|
138
|
+
args.push("-tb", String(runtime.threadsBatch));
|
|
139
|
+
if (runtime.flashAttention !== undefined)
|
|
140
|
+
args.push("-fa", runtime.flashAttention ? "on" : "off");
|
|
141
|
+
if (runtime.reasoningBudget !== undefined) {
|
|
142
|
+
args.push("--reasoning-budget", String(runtime.reasoningBudget));
|
|
143
|
+
}
|
|
144
|
+
const child = spawn("llama-server", args, {
|
|
145
|
+
detached: true,
|
|
146
|
+
stdio: ["ignore", out.fd, err.fd],
|
|
147
|
+
});
|
|
148
|
+
child.unref();
|
|
149
|
+
if (child.pid)
|
|
150
|
+
await writeFile(pidPath(runtime), String(child.pid));
|
|
151
|
+
await out.close();
|
|
152
|
+
await err.close();
|
|
153
|
+
}
|
|
154
|
+
async function stopManagedServer(runtime) {
|
|
155
|
+
const pid = await managedServerPid(runtime);
|
|
156
|
+
if (!pid) {
|
|
157
|
+
const runningModel = await runningServerModel(runtime.baseUrl);
|
|
158
|
+
throw new Error(`A llama-server is already running with ${runningModel ?? "another model"}.
|
|
159
|
+
Stop it before switching models, or use STUPIFY_LLAMA_SERVER_URL for that server.`);
|
|
160
|
+
}
|
|
161
|
+
console.error(`Restarting local ${runtime.profile} model server for selected model.`);
|
|
162
|
+
try {
|
|
163
|
+
process.kill(pid, "SIGTERM");
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
await rm(pidPath(runtime), { force: true });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const deadline = Date.now() + 15_000;
|
|
170
|
+
while (Date.now() < deadline) {
|
|
171
|
+
if (!(await runningServerModel(runtime.baseUrl))) {
|
|
172
|
+
await rm(pidPath(runtime), { force: true });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
await sleep(250);
|
|
176
|
+
}
|
|
177
|
+
throw new Error("Timed out while stopping existing llama-server.");
|
|
178
|
+
}
|
|
179
|
+
async function managedServerPid(runtime) {
|
|
180
|
+
try {
|
|
181
|
+
const value = Number((await readFile(pidPath(runtime), "utf8")).trim());
|
|
182
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function pidPath(runtime) {
|
|
189
|
+
return path.join(cacheDir(), "llama-server.pid");
|
|
190
|
+
}
|
|
191
|
+
function envInteger(name, fallback) {
|
|
192
|
+
const raw = process.env[name];
|
|
193
|
+
if (raw === undefined || raw === "")
|
|
194
|
+
return fallback;
|
|
195
|
+
const value = Number(raw);
|
|
196
|
+
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
197
|
+
}
|
|
198
|
+
function envBoolean(name) {
|
|
199
|
+
const raw = process.env[name];
|
|
200
|
+
if (raw === undefined || raw === "")
|
|
201
|
+
return undefined;
|
|
202
|
+
return /^(1|true|yes|on)$/i.test(raw);
|
|
203
|
+
}
|
|
204
|
+
async function waitForServer(baseUrl, modelId) {
|
|
205
|
+
const deadline = Date.now() + 120_000;
|
|
206
|
+
while (Date.now() < deadline) {
|
|
207
|
+
const runningModel = await runningServerModel(baseUrl);
|
|
208
|
+
if (runningModel === modelId)
|
|
209
|
+
return;
|
|
210
|
+
await sleep(500);
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`llama-server did not become ready for ${modelId}.`);
|
|
213
|
+
}
|
|
214
|
+
function sleep(ms) {
|
|
215
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
216
|
+
}
|
|
217
|
+
async function downloadModel(modelPath, modelUrl) {
|
|
218
|
+
const tempPath = `${modelPath}.download`;
|
|
219
|
+
await rm(tempPath, { force: true });
|
|
220
|
+
console.error("Downloading model...");
|
|
221
|
+
try {
|
|
222
|
+
const response = await fetch(modelUrl);
|
|
223
|
+
if (!response.ok || !response.body)
|
|
224
|
+
throw new Error(`Model download failed: HTTP ${response.status}`);
|
|
225
|
+
const total = Number(response.headers.get("content-length") ?? 0);
|
|
226
|
+
let received = 0;
|
|
227
|
+
let lastPrint = 0;
|
|
228
|
+
const reader = response.body.getReader();
|
|
229
|
+
const file = await open(tempPath, "wx");
|
|
230
|
+
try {
|
|
231
|
+
while (true) {
|
|
232
|
+
const { done, value } = await reader.read();
|
|
233
|
+
if (done)
|
|
234
|
+
break;
|
|
235
|
+
received += value.byteLength;
|
|
236
|
+
await file.write(value);
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
if (total > 0 && now - lastPrint > 500) {
|
|
239
|
+
lastPrint = now;
|
|
240
|
+
statusOutput.write(`\r${formatBytes(received)} / ${formatBytes(total)}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await file.close();
|
|
246
|
+
}
|
|
247
|
+
if (total > 0)
|
|
248
|
+
statusOutput.write(`\r${formatBytes(received)} / ${formatBytes(total)}\n`);
|
|
249
|
+
await rename(tempPath, modelPath);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
await rm(tempPath, { force: true });
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async function confirm(question) {
|
|
257
|
+
const rl = createInterface(terminalIo());
|
|
258
|
+
try {
|
|
259
|
+
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
260
|
+
return answer === "y" || answer === "yes";
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
rl.close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function terminalIo() {
|
|
267
|
+
if (input.isTTY)
|
|
268
|
+
return { input, output };
|
|
269
|
+
if (platform() !== "win32")
|
|
270
|
+
return {
|
|
271
|
+
input: createReadStream("/dev/tty"),
|
|
272
|
+
output: createWriteStream("/dev/tty"),
|
|
273
|
+
};
|
|
274
|
+
throw new Error("No local Stupify model found. Run `stupify` once in an interactive terminal to set up the model.");
|
|
275
|
+
}
|
|
276
|
+
function cacheDir() {
|
|
277
|
+
if (process.env.STUPIFY_CACHE_DIR)
|
|
278
|
+
return process.env.STUPIFY_CACHE_DIR;
|
|
279
|
+
if (process.env.XDG_CACHE_HOME)
|
|
280
|
+
return path.join(process.env.XDG_CACHE_HOME, "stupify");
|
|
281
|
+
if (platform() === "darwin")
|
|
282
|
+
return path.join(homedir(), "Library", "Caches", "stupify");
|
|
283
|
+
if (platform() === "win32" && process.env.LOCALAPPDATA)
|
|
284
|
+
return path.join(process.env.LOCALAPPDATA, "stupify", "Cache");
|
|
285
|
+
return path.join(homedir(), ".cache", "stupify");
|
|
286
|
+
}
|
|
287
|
+
async function exists(filePath) {
|
|
288
|
+
try {
|
|
289
|
+
return (await stat(filePath)).isFile();
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function formatBytes(bytes) {
|
|
296
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
297
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SemChangeSet, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
|
|
2
|
+
export declare function searchPrompt(input: Readonly<{
|
|
3
|
+
changeSet: SemChangeSet;
|
|
4
|
+
contexts: readonly SemContext[];
|
|
5
|
+
pack: SemContextPack;
|
|
6
|
+
patterns: readonly StupifyCheck[];
|
|
7
|
+
includeCounterReason: boolean;
|
|
8
|
+
}>): string;
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export function searchPrompt(input) {
|
|
2
|
+
return `You are Stupify's local search model.
|
|
3
|
+
Stupify checks whether AI-assisted coding may be replacing developer judgment.
|
|
4
|
+
You will receive:
|
|
5
|
+
1. Semantic changed entities selected by a fast local counter.
|
|
6
|
+
2. Compressed local file context from Repomix.
|
|
7
|
+
3. A list of search targets. Each target has exactly one assigned pattern.
|
|
8
|
+
|
|
9
|
+
Your job:
|
|
10
|
+
Evaluate each target only against its assigned pattern.
|
|
11
|
+
False positives are expensive.
|
|
12
|
+
Only emit a match if the assigned pattern clearly applies to that exact target.
|
|
13
|
+
Do not perform general code review.
|
|
14
|
+
Do not suggest improvements.
|
|
15
|
+
Do not choose a pattern.
|
|
16
|
+
Do not apply other patterns.
|
|
17
|
+
Do not report issues for unlisted targets.
|
|
18
|
+
Do not emit clean results.
|
|
19
|
+
Omitted target = clean.
|
|
20
|
+
Return JSON only:
|
|
21
|
+
{
|
|
22
|
+
"matches": [
|
|
23
|
+
{
|
|
24
|
+
"targetId": "t001",
|
|
25
|
+
"reason": "one sentence",
|
|
26
|
+
"proof": "short pointer"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
- Use only targetIds from the input.
|
|
33
|
+
- Emit at most 5 matches.
|
|
34
|
+
- Prefer omission over a weak match.
|
|
35
|
+
- Do not quote source code.
|
|
36
|
+
- Do not write generic feedback.
|
|
37
|
+
- Do not emit "no evidence" or "does not apply."
|
|
38
|
+
- Proof must point to concrete changed product code that implements the pattern.
|
|
39
|
+
- Proof must not be a file header or start with "diff --git".
|
|
40
|
+
- Do not use pattern registry text, prompt text, docs, tests, or examples as proof.
|
|
41
|
+
- Do not treat pattern or prompt wording as the code being evaluated.
|
|
42
|
+
- Do not treat plain conditionals, guard clauses, skip paths, or error handling as indirection.
|
|
43
|
+
- For unnecessary_complexity, identify the exact new named abstraction in proof.
|
|
44
|
+
- If unnecessary_complexity proof would only be a file, hunk, or conditional block, omit it.
|
|
45
|
+
- If nothing clearly matches, return { "matches": [] }.
|
|
46
|
+
|
|
47
|
+
SOURCE:
|
|
48
|
+
${input.changeSet.label}
|
|
49
|
+
|
|
50
|
+
SEARCH TARGETS:
|
|
51
|
+
${input.contexts.map((context) => formatSearchTarget(context, patternForContext(context, input.patterns), input.includeCounterReason)).join("\n\n") || "(none)"}
|
|
52
|
+
|
|
53
|
+
REPOMIX CONTEXT (${input.pack.filePaths.length} files, ${input.pack.totalTokens} tokens):
|
|
54
|
+
${input.pack.text || "(none)"}`;
|
|
55
|
+
}
|
|
56
|
+
function formatSearchPattern(check) {
|
|
57
|
+
return `Pattern: ${check.id} (${check.name})
|
|
58
|
+
Question: ${check.searchPrompt ?? check.question}
|
|
59
|
+
Look for:
|
|
60
|
+
${check.lookFor.map((signal) => `- ${signal}`).join("\n")}
|
|
61
|
+
Ignore when:
|
|
62
|
+
${check.ignoreWhen.map((signal) => `- ${signal}`).join("\n")}
|
|
63
|
+
Match examples:
|
|
64
|
+
${(check.searchExamples?.match ?? check.examples?.match ?? []).map((example) => `- ${example}`).join("\n")}
|
|
65
|
+
Non-match examples:
|
|
66
|
+
${(check.searchExamples?.nonMatch ?? check.examples?.noMatch ?? []).map((example) => `- ${example}`).join("\n")}`;
|
|
67
|
+
}
|
|
68
|
+
function formatSearchTarget(context, pattern, includeCounterReason) {
|
|
69
|
+
return `TARGET ${context.targetId}
|
|
70
|
+
ASSIGNED ${formatSearchPattern(pattern)}
|
|
71
|
+
SEM TARGET:
|
|
72
|
+
ENTITY ${context.entityId}
|
|
73
|
+
NAME ${context.entityName}
|
|
74
|
+
KIND ${context.entityKind}
|
|
75
|
+
CHANGE ${context.changeKind}
|
|
76
|
+
FILE ${context.filePath ?? "(unknown)"}
|
|
77
|
+
${includeCounterReason ? `COUNTER_REASON ${context.reason}` : ""}`.trim();
|
|
78
|
+
}
|
|
79
|
+
function patternForContext(context, patterns) {
|
|
80
|
+
return patterns.find((pattern) => pattern.id === context.checkId) ?? {
|
|
81
|
+
id: context.checkId,
|
|
82
|
+
name: context.checkId,
|
|
83
|
+
question: `Does this target match ${context.checkId}?`,
|
|
84
|
+
lookFor: [],
|
|
85
|
+
ignoreWhen: [],
|
|
86
|
+
};
|
|
87
|
+
}
|
package/dist/render.d.ts
ADDED
package/dist/render.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { VERSION } from "./constants.js";
|
|
2
|
+
export function renderSearchRun(run, command) {
|
|
3
|
+
if (command.json)
|
|
4
|
+
return JSON.stringify(run, null, 2);
|
|
5
|
+
if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
|
|
6
|
+
return `🧙 stupify 🪄
|
|
7
|
+
Search input is too large for precise local search.
|
|
8
|
+
Size:
|
|
9
|
+
~${run.stats.inputTokens ?? "unknown"} tokens
|
|
10
|
+
Limit:
|
|
11
|
+
${run.stats.inputTokenCap ?? "unknown"} tokens
|
|
12
|
+
Stupify skipped the search rather than review truncated context.
|
|
13
|
+
Nothing was blocked.
|
|
14
|
+
Try:
|
|
15
|
+
stupify ${sourceHint(command)} --max-search-input-tokens ${Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2)}`;
|
|
16
|
+
}
|
|
17
|
+
if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
|
|
18
|
+
return `🧙 stupify 🪄
|
|
19
|
+
Search complete.
|
|
20
|
+
Patterns: ${run.patterns.join(", ")}
|
|
21
|
+
No search targets found.`;
|
|
22
|
+
}
|
|
23
|
+
if (run.matches.length === 0) {
|
|
24
|
+
return `🧙 stupify 🪄
|
|
25
|
+
Search complete.
|
|
26
|
+
Patterns: ${run.patterns.join(", ")}
|
|
27
|
+
No judgment-offload signals found.`;
|
|
28
|
+
}
|
|
29
|
+
return `🧙 stupify 🪄
|
|
30
|
+
Possible judgment-offload detected:
|
|
31
|
+
${run.matches.map((match, index) => `${index + 1}. ${match.patternId}
|
|
32
|
+
${match.reason}
|
|
33
|
+
Proof: ${match.proof}`).join("\n")}
|
|
34
|
+
Search mode is warn-only.`;
|
|
35
|
+
}
|
|
36
|
+
export function helpText() {
|
|
37
|
+
return `Stupify ${VERSION}
|
|
38
|
+
|
|
39
|
+
Usage:
|
|
40
|
+
stupify
|
|
41
|
+
stupify --since "2 weeks ago"
|
|
42
|
+
stupify --commit <commit>
|
|
43
|
+
stupify --commits <count>
|
|
44
|
+
stupify --staged
|
|
45
|
+
stupify --mode search --staged
|
|
46
|
+
stupify hook install|uninstall|status
|
|
47
|
+
stupify doctor
|
|
48
|
+
stupify bench search experiments/search-bench.json
|
|
49
|
+
git diff HEAD~1..HEAD | stupify --stdin
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
--staged Search staged changes.
|
|
53
|
+
--mode <mode> search. Search is the only analysis mode.
|
|
54
|
+
--since <date> Search the net diff from the first commit before this git date to HEAD.
|
|
55
|
+
--commit <commit> Search one commit as a net diff.
|
|
56
|
+
--commits <count> Search the net diff across the last N non-merge commits.
|
|
57
|
+
--stdin Read a git diff from stdin.
|
|
58
|
+
--debug-sem Print sem commands and stderr.
|
|
59
|
+
--max-candidates <n> Max semantic search targets. Default: 10.
|
|
60
|
+
--max-search-input-tokens <n>
|
|
61
|
+
Max search input tokens before skipping. Default: 12000.
|
|
62
|
+
--checks <ids> Comma-separated pattern ids.
|
|
63
|
+
--model <id> gemma-4-e2b, gemma-4-e4b, gemma-4-26b-a4b, qwen3-4b-magicquant, qwen2.5-coder-1.5b, qwen2.5-coder-7b, or qwen2.5-coder-32b.
|
|
64
|
+
--search-profile <path>
|
|
65
|
+
Dev/bench-only search profile override.
|
|
66
|
+
--include-counter-reason-in-prompt
|
|
67
|
+
Debug/bench-only: include counter reason in the model prompt.
|
|
68
|
+
--json Print JSON only.
|
|
69
|
+
|
|
70
|
+
Diagnostics:
|
|
71
|
+
stupify doctor Check local setup, hook status, and privacy boundary.
|
|
72
|
+
|
|
73
|
+
Default:
|
|
74
|
+
stupify is equivalent to stupify --since "2 weeks ago".
|
|
75
|
+
|
|
76
|
+
Pipeline:
|
|
77
|
+
sem diff -> counter scout -> Repomix context -> local search model.
|
|
78
|
+
|
|
79
|
+
Not included:
|
|
80
|
+
Findings audit, validators, judges, baselines, sharing, hosted server calls, GitHub, dashboards, or repo-wide crawling.
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
function sourceHint(command) {
|
|
84
|
+
if (command.kind === "staged")
|
|
85
|
+
return "--staged";
|
|
86
|
+
if (command.kind === "since")
|
|
87
|
+
return `--since "${command.since}"`;
|
|
88
|
+
if (command.kind === "commit")
|
|
89
|
+
return `--commit ${command.commit}`;
|
|
90
|
+
if (command.kind === "commits")
|
|
91
|
+
return `--commits ${command.count}`;
|
|
92
|
+
return "--stdin";
|
|
93
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RepomixSearchConfig, SemCandidate, SemChange, SemContext, SemContextPack } from "./types.ts";
|
|
2
|
+
export declare function emptyContextPack(): SemContextPack;
|
|
3
|
+
export declare function repomixContextPack(cwd: string, contexts: readonly SemContext[], changes: readonly SemChange[], config?: Readonly<{
|
|
4
|
+
compress: boolean;
|
|
5
|
+
showLineNumbers: boolean;
|
|
6
|
+
removeEmptyLines: boolean;
|
|
7
|
+
maxFileSizeBytes: number;
|
|
8
|
+
maxTotalSizeBytes: number;
|
|
9
|
+
ignorePatterns: readonly string[];
|
|
10
|
+
}>): Promise<SemContextPack>;
|
|
11
|
+
export declare function entityContextsFromChanges(candidates: readonly SemCandidate[], changes: readonly SemChange[]): readonly SemContext[];
|
|
12
|
+
export declare function repomixSearchConfig(): RepomixSearchConfig;
|