@stupify/cli 0.0.8 → 0.0.10
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/dist/analysis.js +3 -2
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/git.d.ts +1 -0
- package/dist/git.js +44 -1
- package/dist/model.d.ts +3 -2
- package/dist/model.js +42 -43
- package/dist/render.js +38 -20
- package/dist/sem-provider.js +17 -12
- package/dist/stupify.d.ts +35 -1
- package/dist/stupify.js +28 -19
- package/dist/types.d.ts +3 -0
- package/dist/ui.d.ts +34 -0
- package/dist/ui.js +143 -0
- package/package.json +3 -1
- package/src/analysis.ts +3 -2
- package/src/constants.ts +1 -1
- package/src/git.ts +44 -1
- package/src/model.ts +49 -55
- package/src/render.ts +34 -20
- package/src/sem-provider.ts +31 -16
- package/src/stupify.ts +31 -18
- package/src/types.ts +3 -0
- package/src/ui.ts +187 -0
package/dist/stupify.js
CHANGED
|
@@ -13,44 +13,47 @@ import { helpText, renderSearchRun } from "./render.js";
|
|
|
13
13
|
import { effectiveMaxCandidates, effectiveMaxSearchInputTokens, effectiveRepomixConfig, effectiveSearchChecks, loadSearchProfile, } from "./search-profile.js";
|
|
14
14
|
import { semChangeSetForCommand } from "./sem-provider.js";
|
|
15
15
|
import { createTracer } from "./trace.js";
|
|
16
|
+
import { createCliUi } from "./ui.js";
|
|
16
17
|
export async function main(argv = process.argv.slice(2)) {
|
|
17
18
|
const startedAt = Date.now();
|
|
19
|
+
let ui = createCliUi();
|
|
18
20
|
try {
|
|
19
21
|
const command = parseCommand(argv);
|
|
20
22
|
if (command.kind === "help") {
|
|
21
|
-
|
|
23
|
+
ui.writeStdout(helpText());
|
|
22
24
|
return 0;
|
|
23
25
|
}
|
|
24
26
|
if (command.kind === "hook") {
|
|
25
|
-
|
|
27
|
+
ui.writeStdout(await runHookCommand(command.action));
|
|
26
28
|
return 0;
|
|
27
29
|
}
|
|
28
30
|
if (command.kind === "doctor") {
|
|
29
31
|
const result = await runDoctor();
|
|
30
|
-
|
|
32
|
+
ui.writeStdout(result.text);
|
|
31
33
|
return result.exitCode;
|
|
32
34
|
}
|
|
33
35
|
if (command.kind === "bench-search") {
|
|
34
36
|
const { runSearchBench } = await import("./search-bench.js");
|
|
35
|
-
|
|
37
|
+
ui.writeStdout(await runSearchBench(command.configPath));
|
|
36
38
|
return 0;
|
|
37
39
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
ui = createCliUi({ quiet: command.json });
|
|
41
|
+
const run = await runSearchCommand(command, startedAt, ui);
|
|
42
|
+
ui.writeStdout(renderSearchRun(run, command));
|
|
40
43
|
return 0;
|
|
41
44
|
}
|
|
42
45
|
catch (error) {
|
|
43
|
-
|
|
46
|
+
ui.error(error instanceof Error ? error.message : String(error), { force: true });
|
|
44
47
|
return 1;
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
|
-
export async function runSearchCommand(command, startedAt) {
|
|
50
|
+
export async function runSearchCommand(command, startedAt, ui = createCliUi({ quiet: command.json })) {
|
|
48
51
|
const t = createTracer({
|
|
49
52
|
writeLine: () => undefined,
|
|
50
53
|
onEvent: (event) => {
|
|
51
54
|
if (command.json)
|
|
52
55
|
return;
|
|
53
|
-
|
|
56
|
+
ui.step(formatStep(event.name, event.ms, event.count, event.detail));
|
|
54
57
|
},
|
|
55
58
|
});
|
|
56
59
|
const profile = await loadSearchProfile(command.searchProfilePath);
|
|
@@ -58,7 +61,7 @@ export async function runSearchCommand(command, startedAt) {
|
|
|
58
61
|
const patternIds = checks.map((check) => check.id);
|
|
59
62
|
const maxCandidates = effectiveMaxCandidates(command.maxCandidates, profile);
|
|
60
63
|
const maxSearchInputTokens = effectiveMaxSearchInputTokens(command.maxSearchInputTokens, profile);
|
|
61
|
-
printRunPlan(command, patternIds);
|
|
64
|
+
printRunPlan(command, patternIds, ui);
|
|
62
65
|
const { value: changeSet } = await t.trace("entity.diff", () => semChangeSetForCommand(command), {
|
|
63
66
|
count: (v) => v.summary.total,
|
|
64
67
|
detail: (v) => `${v.summary.fileCount} files`,
|
|
@@ -78,6 +81,7 @@ export async function runSearchCommand(command, startedAt) {
|
|
|
78
81
|
stats: {
|
|
79
82
|
elapsedMs: Date.now() - startedAt,
|
|
80
83
|
modelCalls: 0,
|
|
84
|
+
committers: changeSet.committers,
|
|
81
85
|
skipped: true,
|
|
82
86
|
skipReason: "no_candidates",
|
|
83
87
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -114,6 +118,7 @@ export async function runSearchCommand(command, startedAt) {
|
|
|
114
118
|
stats: {
|
|
115
119
|
elapsedMs: Date.now() - startedAt,
|
|
116
120
|
modelCalls: 0,
|
|
121
|
+
committers: changeSet.committers,
|
|
117
122
|
skipped: true,
|
|
118
123
|
skipReason: "no_candidates",
|
|
119
124
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -156,6 +161,7 @@ export async function runSearchCommand(command, startedAt) {
|
|
|
156
161
|
modelCalls: 0,
|
|
157
162
|
inputTokens: batches.estimatedInputTokens,
|
|
158
163
|
inputTokenCap: maxSearchInputTokens,
|
|
164
|
+
committers: changeSet.committers,
|
|
159
165
|
skipped: true,
|
|
160
166
|
skipReason: "input_too_large",
|
|
161
167
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -175,13 +181,13 @@ export async function runSearchCommand(command, startedAt) {
|
|
|
175
181
|
};
|
|
176
182
|
}
|
|
177
183
|
if (batches.wasSplit && !command.json) {
|
|
178
|
-
|
|
184
|
+
ui.warn(`Search input is large; queued ${batches.batches.length} smaller search batches.`);
|
|
179
185
|
if (batches.skippedTargets > 0) {
|
|
180
|
-
|
|
186
|
+
ui.warn(`Skipped ${batches.skippedTargets} oversized targets that could not fit alone.`);
|
|
181
187
|
}
|
|
182
188
|
}
|
|
183
|
-
const modelPath = await firstRunModelBootstrap(command.model);
|
|
184
|
-
const model = await loadLocalModel(modelPath, command.model, "scout");
|
|
189
|
+
const modelPath = await firstRunModelBootstrap(command.model, ui);
|
|
190
|
+
const model = await loadLocalModel(modelPath, command.model, "scout", ui);
|
|
185
191
|
const matches = [];
|
|
186
192
|
let modelCalls = 0;
|
|
187
193
|
let inputTokens = 0;
|
|
@@ -192,7 +198,7 @@ export async function runSearchCommand(command, startedAt) {
|
|
|
192
198
|
if (batchInputTokens > maxSearchInputTokens) {
|
|
193
199
|
exactSkippedTargets += batch.contexts.length;
|
|
194
200
|
if (!command.json) {
|
|
195
|
-
|
|
201
|
+
ui.warn(`Skipped ${batch.contexts.length} targets after exact token count exceeded the limit.`);
|
|
196
202
|
}
|
|
197
203
|
continue;
|
|
198
204
|
}
|
|
@@ -212,6 +218,7 @@ export async function runSearchCommand(command, startedAt) {
|
|
|
212
218
|
modelCalls,
|
|
213
219
|
inputTokens,
|
|
214
220
|
inputTokenCap: maxSearchInputTokens,
|
|
221
|
+
committers: changeSet.committers,
|
|
215
222
|
filesChanged: changeSet.summary.fileCount,
|
|
216
223
|
entitiesScanned: changeSet.summary.total,
|
|
217
224
|
candidates: contexts.length,
|
|
@@ -320,12 +327,14 @@ function buildSearchRequest(changeSet, contexts, pack, patterns, profile, includ
|
|
|
320
327
|
includeCounterReasonInPrompt: profile?.includeCounterReasonInPrompt ?? includeCounterReasonInPrompt,
|
|
321
328
|
});
|
|
322
329
|
}
|
|
323
|
-
function printRunPlan(command, patternIds) {
|
|
330
|
+
function printRunPlan(command, patternIds, ui) {
|
|
324
331
|
if (command.json)
|
|
325
332
|
return;
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
333
|
+
ui.intro("stupify");
|
|
334
|
+
ui.note([
|
|
335
|
+
`Search: ${sourceLabel(command)}`,
|
|
336
|
+
`Patterns: ${patternIds.join(", ")}`,
|
|
337
|
+
].join("\n"), "Run");
|
|
329
338
|
}
|
|
330
339
|
function formatStep(name, ms, count, detail) {
|
|
331
340
|
if (name === "entity.diff")
|
package/dist/types.d.ts
CHANGED
|
@@ -105,6 +105,7 @@ export type SourceRange = Readonly<{
|
|
|
105
105
|
label: string;
|
|
106
106
|
base: string;
|
|
107
107
|
target: string;
|
|
108
|
+
committers?: readonly string[];
|
|
108
109
|
stats: NetDiffStats;
|
|
109
110
|
}>;
|
|
110
111
|
export type SemChange = Readonly<{
|
|
@@ -130,6 +131,7 @@ export type SemChangeSet = Readonly<{
|
|
|
130
131
|
label: string;
|
|
131
132
|
base: string;
|
|
132
133
|
target: string;
|
|
134
|
+
committers?: readonly string[];
|
|
133
135
|
contextCwd: string;
|
|
134
136
|
cleanup: () => Promise<void>;
|
|
135
137
|
changes: readonly SemChange[];
|
|
@@ -214,6 +216,7 @@ export type SearchRunJson = Readonly<{
|
|
|
214
216
|
inputTokenCap?: number;
|
|
215
217
|
skipped?: boolean;
|
|
216
218
|
skipReason?: "input_too_large" | "no_candidates";
|
|
219
|
+
committers?: readonly string[];
|
|
217
220
|
filesChanged?: number;
|
|
218
221
|
entitiesScanned?: number;
|
|
219
222
|
candidates?: number;
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type SpinnerResult } from "@clack/prompts";
|
|
2
|
+
export type CliUi = ReturnType<typeof createCliUi>;
|
|
3
|
+
export type CliUiOptions = Readonly<{
|
|
4
|
+
quiet?: boolean;
|
|
5
|
+
}>;
|
|
6
|
+
type LogOptions = Readonly<{
|
|
7
|
+
force?: boolean;
|
|
8
|
+
}>;
|
|
9
|
+
export declare function createCliUi(options?: CliUiOptions): {
|
|
10
|
+
intro(title: string, logOptions?: LogOptions): void;
|
|
11
|
+
outro(message: string, logOptions?: LogOptions): void;
|
|
12
|
+
note(message: string, title?: string, logOptions?: LogOptions): void;
|
|
13
|
+
info(message: string, logOptions?: LogOptions): void;
|
|
14
|
+
step(message: string, logOptions?: LogOptions): void;
|
|
15
|
+
success(message: string, logOptions?: LogOptions): void;
|
|
16
|
+
warn(message: string, logOptions?: LogOptions): void;
|
|
17
|
+
error(message: string, logOptions?: LogOptions): void;
|
|
18
|
+
debug(message: string): void;
|
|
19
|
+
confirm(message: string): Promise<boolean>;
|
|
20
|
+
spinner(message: string, logOptions?: LogOptions): SpinnerResult;
|
|
21
|
+
progress(message: string, max: number, logOptions?: LogOptions): import("@clack/prompts").ProgressResult;
|
|
22
|
+
writeStdout(text: string): void;
|
|
23
|
+
};
|
|
24
|
+
export declare const format: {
|
|
25
|
+
heading: (value: string) => string;
|
|
26
|
+
label: (value: string) => string;
|
|
27
|
+
muted: (value: string) => string;
|
|
28
|
+
success: (value: string) => string;
|
|
29
|
+
warn: (value: string) => string;
|
|
30
|
+
error: (value: string) => string;
|
|
31
|
+
};
|
|
32
|
+
export declare function diagnostic(message: string): void;
|
|
33
|
+
export declare function diagnosticError(message: string): void;
|
|
34
|
+
export {};
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { confirm as clackConfirm, intro as clackIntro, isCancel, log, note, outro as clackOutro, progress, spinner, } from "@clack/prompts";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { platform } from "node:os";
|
|
4
|
+
import { stdin, stderr, stdout } from "node:process";
|
|
5
|
+
import pc from "picocolors";
|
|
6
|
+
export function createCliUi(options = {}) {
|
|
7
|
+
const quiet = options.quiet ?? false;
|
|
8
|
+
const output = stderr;
|
|
9
|
+
function shouldWrite(logOptions) {
|
|
10
|
+
return logOptions?.force === true || !quiet;
|
|
11
|
+
}
|
|
12
|
+
function withPromptIo(run) {
|
|
13
|
+
const io = promptIo();
|
|
14
|
+
return run(io).finally(() => io.close());
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
intro(title, logOptions) {
|
|
18
|
+
if (shouldWrite(logOptions))
|
|
19
|
+
clackIntro(title, { output });
|
|
20
|
+
},
|
|
21
|
+
outro(message, logOptions) {
|
|
22
|
+
if (shouldWrite(logOptions))
|
|
23
|
+
clackOutro(message, { output });
|
|
24
|
+
},
|
|
25
|
+
note(message, title, logOptions) {
|
|
26
|
+
if (shouldWrite(logOptions))
|
|
27
|
+
note(message, title, { output });
|
|
28
|
+
},
|
|
29
|
+
info(message, logOptions) {
|
|
30
|
+
if (shouldWrite(logOptions))
|
|
31
|
+
log.info(message, { output });
|
|
32
|
+
},
|
|
33
|
+
step(message, logOptions) {
|
|
34
|
+
if (shouldWrite(logOptions))
|
|
35
|
+
log.step(message, { output });
|
|
36
|
+
},
|
|
37
|
+
success(message, logOptions) {
|
|
38
|
+
if (shouldWrite(logOptions))
|
|
39
|
+
log.success(message, { output });
|
|
40
|
+
},
|
|
41
|
+
warn(message, logOptions) {
|
|
42
|
+
if (shouldWrite(logOptions))
|
|
43
|
+
log.warn(message, { output });
|
|
44
|
+
},
|
|
45
|
+
error(message, logOptions) {
|
|
46
|
+
if (shouldWrite(logOptions))
|
|
47
|
+
log.error(message, { output });
|
|
48
|
+
},
|
|
49
|
+
debug(message) {
|
|
50
|
+
if (!quiet)
|
|
51
|
+
log.message(message, { output, symbol: pc.dim("trace") });
|
|
52
|
+
},
|
|
53
|
+
async confirm(message) {
|
|
54
|
+
return withPromptIo(async (io) => {
|
|
55
|
+
const result = await clackConfirm({
|
|
56
|
+
message,
|
|
57
|
+
active: "Yes",
|
|
58
|
+
inactive: "No",
|
|
59
|
+
initialValue: false,
|
|
60
|
+
input: io.input,
|
|
61
|
+
output: io.output,
|
|
62
|
+
});
|
|
63
|
+
if (isCancel(result))
|
|
64
|
+
return false;
|
|
65
|
+
return result;
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
spinner(message, logOptions) {
|
|
69
|
+
if (!shouldWrite(logOptions))
|
|
70
|
+
return silentSpinner();
|
|
71
|
+
const active = spinner({ output });
|
|
72
|
+
active.start(message);
|
|
73
|
+
return active;
|
|
74
|
+
},
|
|
75
|
+
progress(message, max, logOptions) {
|
|
76
|
+
if (!shouldWrite(logOptions))
|
|
77
|
+
return silentProgress();
|
|
78
|
+
const active = progress({ output, max });
|
|
79
|
+
active.start(message);
|
|
80
|
+
return active;
|
|
81
|
+
},
|
|
82
|
+
writeStdout(text) {
|
|
83
|
+
stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export const format = {
|
|
88
|
+
heading: (value) => pc.bold(value),
|
|
89
|
+
label: (value) => pc.cyan(value),
|
|
90
|
+
muted: (value) => pc.dim(value),
|
|
91
|
+
success: (value) => pc.green(value),
|
|
92
|
+
warn: (value) => pc.yellow(value),
|
|
93
|
+
error: (value) => pc.red(value),
|
|
94
|
+
};
|
|
95
|
+
export function diagnostic(message) {
|
|
96
|
+
log.message(message, {
|
|
97
|
+
output: stderr,
|
|
98
|
+
symbol: pc.dim("·"),
|
|
99
|
+
spacing: 0,
|
|
100
|
+
withGuide: false,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function diagnosticError(message) {
|
|
104
|
+
log.error(message, { output: stderr, spacing: 0, withGuide: false });
|
|
105
|
+
}
|
|
106
|
+
function promptIo() {
|
|
107
|
+
if (stdin.isTTY && stderr.isTTY) {
|
|
108
|
+
return { input: stdin, output: stderr, close: () => undefined };
|
|
109
|
+
}
|
|
110
|
+
if (platform() === "win32") {
|
|
111
|
+
throw new Error("No interactive terminal found. Run `stupify` once in an interactive terminal to set up the model.");
|
|
112
|
+
}
|
|
113
|
+
const input = createReadStream("/dev/tty");
|
|
114
|
+
const output = createWriteStream("/dev/tty");
|
|
115
|
+
return {
|
|
116
|
+
input,
|
|
117
|
+
output,
|
|
118
|
+
close: () => closePromptIo(input, output),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function closePromptIo(input, output) {
|
|
122
|
+
input.destroy();
|
|
123
|
+
output.end();
|
|
124
|
+
}
|
|
125
|
+
function silentSpinner() {
|
|
126
|
+
return {
|
|
127
|
+
start: () => undefined,
|
|
128
|
+
stop: () => undefined,
|
|
129
|
+
cancel: () => undefined,
|
|
130
|
+
error: () => undefined,
|
|
131
|
+
message: () => undefined,
|
|
132
|
+
clear: () => undefined,
|
|
133
|
+
get isCancelled() {
|
|
134
|
+
return false;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function silentProgress() {
|
|
139
|
+
return {
|
|
140
|
+
...silentSpinner(),
|
|
141
|
+
advance: () => undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stupify/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "Local-only diagnostic CLI for checking whether AI is making you dumber.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@ataraxy-labs/sem": "^0.3.24",
|
|
47
|
+
"@clack/prompts": "^1.2.0",
|
|
48
|
+
"picocolors": "^1.1.1",
|
|
47
49
|
"repomix": "^1.14.0"
|
|
48
50
|
}
|
|
49
51
|
}
|
package/src/analysis.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { cachedJson, fingerprint } from "./cache.ts";
|
|
|
2
2
|
import type { LocalModel } from "./model.ts";
|
|
3
3
|
import { searchPrompt } from "./prompts.ts";
|
|
4
4
|
import type { SearchMatch, SemChangeSet, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
|
|
5
|
+
import { diagnostic, diagnosticError } from "./ui.ts";
|
|
5
6
|
|
|
6
7
|
export async function runSearch(
|
|
7
8
|
model: LocalModel,
|
|
@@ -150,8 +151,8 @@ Your previous response was not valid JSON. Return the requested JSON object only
|
|
|
150
151
|
const retryParsed = parseJson(retry);
|
|
151
152
|
if (retryParsed.ok) return retryParsed.value;
|
|
152
153
|
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
diagnosticError("Raw model output:");
|
|
155
|
+
diagnostic(retry);
|
|
155
156
|
throw new Error("Model returned invalid JSON.");
|
|
156
157
|
}
|
|
157
158
|
|
package/src/constants.ts
CHANGED
package/src/git.ts
CHANGED
|
@@ -96,6 +96,15 @@ export async function gitPath(pathspec: string): Promise<string> {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
export async function gitUserLabel(): Promise<string> {
|
|
100
|
+
const [name, email] = await Promise.all([
|
|
101
|
+
gitConfig("user.name"),
|
|
102
|
+
gitConfig("user.email"),
|
|
103
|
+
]);
|
|
104
|
+
if (name && email) return `${name} <${email}>`;
|
|
105
|
+
return name || email || "working tree";
|
|
106
|
+
}
|
|
107
|
+
|
|
99
108
|
async function netDiff(base: string, target: string, label: string, id?: NetDiff["id"]): Promise<NetDiff> {
|
|
100
109
|
const [text, stats, shortBase, shortTarget] = await Promise.all([
|
|
101
110
|
diff(base, target),
|
|
@@ -114,20 +123,54 @@ async function netDiff(base: string, target: string, label: string, id?: NetDiff
|
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
async function sourceRange(base: string, target: string, label: string, id?: SourceRange["id"]): Promise<SourceRange> {
|
|
117
|
-
const [stats, shortBase, shortTarget] = await Promise.all([
|
|
126
|
+
const [stats, shortBase, shortTarget, committers] = await Promise.all([
|
|
118
127
|
diffStats(base, target),
|
|
119
128
|
shortCommit(base),
|
|
120
129
|
shortCommit(target),
|
|
130
|
+
committersForRange(base, target),
|
|
121
131
|
]);
|
|
122
132
|
return {
|
|
123
133
|
id: id ?? sourceId(`net:${shortBase}..${shortTarget}`),
|
|
124
134
|
label,
|
|
125
135
|
base,
|
|
126
136
|
target,
|
|
137
|
+
committers,
|
|
127
138
|
stats,
|
|
128
139
|
};
|
|
129
140
|
}
|
|
130
141
|
|
|
142
|
+
async function gitConfig(key: string): Promise<string> {
|
|
143
|
+
try {
|
|
144
|
+
const { stdout } = await execFileAsync("git", ["config", "--get", key]);
|
|
145
|
+
return stdout.trim();
|
|
146
|
+
} catch {
|
|
147
|
+
return "";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function committersForRange(base: string, target: string): Promise<readonly string[]> {
|
|
152
|
+
try {
|
|
153
|
+
const { stdout } = await execFileAsync("git", ["log", "--format=%cn <%ce>", `${base}..${target}`], {
|
|
154
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
155
|
+
});
|
|
156
|
+
return uniqueLines(stdout);
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function uniqueLines(value: string): readonly string[] {
|
|
163
|
+
const seen = new Set<string>();
|
|
164
|
+
const lines: string[] = [];
|
|
165
|
+
for (const line of value.split(/\r?\n/)) {
|
|
166
|
+
const trimmed = line.trim();
|
|
167
|
+
if (!trimmed || seen.has(trimmed)) continue;
|
|
168
|
+
seen.add(trimmed);
|
|
169
|
+
lines.push(trimmed);
|
|
170
|
+
}
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
|
|
131
174
|
async function baseBefore(since: string): Promise<string> {
|
|
132
175
|
try {
|
|
133
176
|
const { stdout } = await execFileAsync("git", [
|
package/src/model.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
|
-
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
2
|
import {
|
|
4
3
|
mkdir,
|
|
5
4
|
open,
|
|
@@ -10,16 +9,11 @@ import {
|
|
|
10
9
|
writeFile,
|
|
11
10
|
} from "node:fs/promises";
|
|
12
11
|
import { homedir, platform } from "node:os";
|
|
13
|
-
import {
|
|
14
|
-
stdin as input,
|
|
15
|
-
stderr as statusOutput,
|
|
16
|
-
stdout as output,
|
|
17
|
-
} from "node:process";
|
|
18
|
-
import { createInterface } from "node:readline/promises";
|
|
19
12
|
import path from "node:path";
|
|
20
13
|
import { promisify } from "node:util";
|
|
21
14
|
import { MODEL_REGISTRY } from "./constants.ts";
|
|
22
15
|
import type { ModelId } from "./types.ts";
|
|
16
|
+
import type { CliUi } from "./ui.ts";
|
|
23
17
|
|
|
24
18
|
const execFileAsync = promisify(execFile);
|
|
25
19
|
const LLAMA_SERVER_HOST = "127.0.0.1";
|
|
@@ -51,22 +45,23 @@ export type LocalModel = Readonly<{
|
|
|
51
45
|
|
|
52
46
|
export async function firstRunModelBootstrap(
|
|
53
47
|
modelId: ModelId,
|
|
48
|
+
ui: CliUi,
|
|
54
49
|
): Promise<string> {
|
|
55
50
|
const selectedModel = MODEL_REGISTRY[modelId];
|
|
56
51
|
const modelDir = path.join(cacheDir(), "models");
|
|
57
52
|
const modelPath = path.join(modelDir, selectedModel.file);
|
|
58
53
|
if (await exists(modelPath)) return modelPath;
|
|
59
54
|
|
|
60
|
-
|
|
55
|
+
ui.note(`No local Stupify model found.
|
|
61
56
|
Stupify runs locally.
|
|
62
57
|
Download this model now?
|
|
63
58
|
Model: ${selectedModel.name}
|
|
64
|
-
Size: ${selectedModel.size}
|
|
59
|
+
Size: ${selectedModel.size}`, "Setup", { force: true });
|
|
65
60
|
|
|
66
|
-
if (!(await confirm("Continue?
|
|
61
|
+
if (!(await ui.confirm("Continue?"))) throw new Error("Setup cancelled.");
|
|
67
62
|
|
|
68
63
|
await mkdir(modelDir, { recursive: true });
|
|
69
|
-
await downloadModel(modelPath, selectedModel.url);
|
|
64
|
+
await downloadModel(modelPath, selectedModel.url, ui);
|
|
70
65
|
if (!(await exists(modelPath)))
|
|
71
66
|
throw new Error("Model download failed: file was not created.");
|
|
72
67
|
return modelPath;
|
|
@@ -75,18 +70,17 @@ Size: ${selectedModel.size}`);
|
|
|
75
70
|
export async function loadLocalModel(
|
|
76
71
|
modelPath: string,
|
|
77
72
|
modelId: ModelId,
|
|
78
|
-
profile: ModelProfile
|
|
73
|
+
profile: ModelProfile,
|
|
74
|
+
ui: CliUi,
|
|
79
75
|
): Promise<LocalModel> {
|
|
80
76
|
const selectedModel = MODEL_REGISTRY[modelId];
|
|
81
77
|
const runtime = modelRuntime(profile);
|
|
82
78
|
const runningModel = await runningServerModel(runtime.baseUrl);
|
|
83
79
|
|
|
84
80
|
if (runningModel) {
|
|
85
|
-
if (runningModel !== modelId) await stopManagedServer(runtime);
|
|
81
|
+
if (runningModel !== modelId) await stopManagedServer(runtime, ui);
|
|
86
82
|
if (runningModel === modelId) {
|
|
87
|
-
|
|
88
|
-
`Using local model: ${selectedModel.name}`,
|
|
89
|
-
);
|
|
83
|
+
ui.info(`Using local model: ${selectedModel.name}`);
|
|
90
84
|
return {
|
|
91
85
|
id: modelId,
|
|
92
86
|
name: selectedModel.name,
|
|
@@ -97,8 +91,15 @@ export async function loadLocalModel(
|
|
|
97
91
|
}
|
|
98
92
|
|
|
99
93
|
await ensureLlamaServerBinary();
|
|
100
|
-
await startLlamaServer(modelPath, modelId, selectedModel.name, runtime);
|
|
101
|
-
|
|
94
|
+
await startLlamaServer(modelPath, modelId, selectedModel.name, runtime, ui);
|
|
95
|
+
const ready = ui.spinner(`Waiting for local ${profile} model`);
|
|
96
|
+
try {
|
|
97
|
+
await waitForServer(runtime.baseUrl, modelId);
|
|
98
|
+
ready.stop(`Local ${profile} model ready`);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
ready.error(`Local ${profile} model failed to start`);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
102
103
|
return {
|
|
103
104
|
id: modelId,
|
|
104
105
|
name: selectedModel.name,
|
|
@@ -159,6 +160,7 @@ async function startLlamaServer(
|
|
|
159
160
|
modelId: ModelId,
|
|
160
161
|
modelName: string,
|
|
161
162
|
runtime: ModelRuntime,
|
|
163
|
+
ui: CliUi,
|
|
162
164
|
): Promise<void> {
|
|
163
165
|
const logDir = path.join(cacheDir(), "logs");
|
|
164
166
|
await mkdir(logDir, { recursive: true });
|
|
@@ -166,8 +168,8 @@ async function startLlamaServer(
|
|
|
166
168
|
const out = await open(logPath, "a");
|
|
167
169
|
const err = await open(logPath, "a");
|
|
168
170
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
+
ui.step(`Starting local model server: ${modelName}`);
|
|
172
|
+
ui.info(`llama-server log: ${logPath}`);
|
|
171
173
|
|
|
172
174
|
const args = [
|
|
173
175
|
"-m",
|
|
@@ -206,7 +208,7 @@ async function startLlamaServer(
|
|
|
206
208
|
await err.close();
|
|
207
209
|
}
|
|
208
210
|
|
|
209
|
-
async function stopManagedServer(runtime: ModelRuntime): Promise<void> {
|
|
211
|
+
async function stopManagedServer(runtime: ModelRuntime, ui: CliUi): Promise<void> {
|
|
210
212
|
const pid = await managedServerPid(runtime);
|
|
211
213
|
if (!pid) {
|
|
212
214
|
const runningModel = await runningServerModel(runtime.baseUrl);
|
|
@@ -214,9 +216,7 @@ async function stopManagedServer(runtime: ModelRuntime): Promise<void> {
|
|
|
214
216
|
Stop it before switching models, or use STUPIFY_LLAMA_SERVER_URL for that server.`);
|
|
215
217
|
}
|
|
216
218
|
|
|
217
|
-
|
|
218
|
-
"Restarting local model server for selected model.",
|
|
219
|
-
);
|
|
219
|
+
ui.step("Restarting local model server for selected model.");
|
|
220
220
|
try {
|
|
221
221
|
process.kill(pid, "SIGTERM");
|
|
222
222
|
} catch {
|
|
@@ -279,11 +279,13 @@ function sleep(ms: number): Promise<void> {
|
|
|
279
279
|
async function downloadModel(
|
|
280
280
|
modelPath: string,
|
|
281
281
|
modelUrl: string,
|
|
282
|
+
ui: CliUi,
|
|
282
283
|
): Promise<void> {
|
|
283
284
|
const tempPath = `${modelPath}.download`;
|
|
284
285
|
await rm(tempPath, { force: true });
|
|
285
286
|
|
|
286
|
-
|
|
287
|
+
const downloadSpinner = ui.spinner("Downloading model");
|
|
288
|
+
let downloadProgress: ReturnType<CliUi["progress"]> | null = null;
|
|
287
289
|
try {
|
|
288
290
|
const response = await fetch(modelUrl);
|
|
289
291
|
if (!response.ok || !response.body)
|
|
@@ -292,8 +294,13 @@ async function downloadModel(
|
|
|
292
294
|
const total = Number(response.headers.get("content-length") ?? 0);
|
|
293
295
|
let received = 0;
|
|
294
296
|
let lastPrint = 0;
|
|
297
|
+
let lastProgressBytes = 0;
|
|
295
298
|
const reader = response.body.getReader();
|
|
296
299
|
const file = await open(tempPath, "wx");
|
|
300
|
+
if (total > 0) {
|
|
301
|
+
downloadSpinner.clear();
|
|
302
|
+
downloadProgress = ui.progress("Downloading model", total);
|
|
303
|
+
}
|
|
297
304
|
|
|
298
305
|
try {
|
|
299
306
|
while (true) {
|
|
@@ -304,51 +311,38 @@ async function downloadModel(
|
|
|
304
311
|
const now = Date.now();
|
|
305
312
|
if (total > 0 && now - lastPrint > 500) {
|
|
306
313
|
lastPrint = now;
|
|
307
|
-
|
|
308
|
-
|
|
314
|
+
downloadProgress?.advance(
|
|
315
|
+
received - lastProgressBytes,
|
|
316
|
+
`${formatBytes(received)} / ${formatBytes(total)}`,
|
|
309
317
|
);
|
|
318
|
+
lastProgressBytes = received;
|
|
310
319
|
}
|
|
311
320
|
}
|
|
312
321
|
} finally {
|
|
313
322
|
await file.close();
|
|
314
323
|
}
|
|
315
324
|
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
|
|
325
|
+
if (downloadProgress && received > lastProgressBytes) {
|
|
326
|
+
downloadProgress.advance(
|
|
327
|
+
received - lastProgressBytes,
|
|
328
|
+
`${formatBytes(received)} / ${formatBytes(total)}`,
|
|
319
329
|
);
|
|
330
|
+
}
|
|
331
|
+
const activeProgress = downloadProgress ?? downloadSpinner;
|
|
332
|
+
activeProgress.stop(
|
|
333
|
+
total > 0
|
|
334
|
+
? `Downloaded ${formatBytes(received)} / ${formatBytes(total)}`
|
|
335
|
+
: "Downloaded model",
|
|
336
|
+
);
|
|
320
337
|
await rename(tempPath, modelPath);
|
|
321
338
|
} catch (error) {
|
|
339
|
+
const activeProgress = downloadProgress ?? downloadSpinner;
|
|
340
|
+
activeProgress.error("Model download failed");
|
|
322
341
|
await rm(tempPath, { force: true });
|
|
323
342
|
throw error;
|
|
324
343
|
}
|
|
325
344
|
}
|
|
326
345
|
|
|
327
|
-
async function confirm(question: string): Promise<boolean> {
|
|
328
|
-
const rl = createInterface(terminalIo());
|
|
329
|
-
try {
|
|
330
|
-
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
331
|
-
return answer === "y" || answer === "yes";
|
|
332
|
-
} finally {
|
|
333
|
-
rl.close();
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function terminalIo(): {
|
|
338
|
-
input: NodeJS.ReadableStream;
|
|
339
|
-
output: NodeJS.WritableStream;
|
|
340
|
-
} {
|
|
341
|
-
if (input.isTTY) return { input, output };
|
|
342
|
-
if (platform() !== "win32")
|
|
343
|
-
return {
|
|
344
|
-
input: createReadStream("/dev/tty"),
|
|
345
|
-
output: createWriteStream("/dev/tty"),
|
|
346
|
-
};
|
|
347
|
-
throw new Error(
|
|
348
|
-
"No local Stupify model found. Run `stupify` once in an interactive terminal to set up the model.",
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
346
|
function cacheDir(): string {
|
|
353
347
|
if (process.env.STUPIFY_CACHE_DIR) return process.env.STUPIFY_CACHE_DIR;
|
|
354
348
|
if (process.env.XDG_CACHE_HOME)
|