@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.4.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/CHANGELOG.md +592 -0
- package/LICENSE +21 -0
- package/README.md +1269 -2
- package/package.json +45 -4
- package/src/agent/agent.js +1194 -0
- package/src/agent/approvals.js +76 -0
- package/src/agent/context.js +106 -0
- package/src/agent/planner.js +237 -0
- package/src/agent/worklog.js +318 -0
- package/src/cli/app.js +471 -0
- package/src/cli/commands.js +1155 -0
- package/src/cli/input.js +287 -0
- package/src/cli/markdown.js +527 -0
- package/src/cli/prompts.js +255 -0
- package/src/cli/renderer.js +2155 -0
- package/src/cli/settings-editor.js +280 -0
- package/src/cli/settings-tui.js +669 -0
- package/src/cli/tui.js +1737 -0
- package/src/cli/width.js +48 -0
- package/src/config/load.js +137 -0
- package/src/config/scaffold.js +50 -0
- package/src/config/settings-registry.js +495 -0
- package/src/config/settings.js +304 -0
- package/src/config/validate.js +461 -0
- package/src/index.js +449 -0
- package/src/providers/catalog.js +407 -0
- package/src/providers/ids.js +24 -0
- package/src/providers/index.js +103 -0
- package/src/providers/nvidia.js +671 -0
- package/src/providers/registry.js +104 -0
- package/src/providers/river.js +198 -0
- package/src/providers/tokenharbor.js +38 -0
- package/src/providers/vendor-extras.js +82 -0
- package/src/services/clipboard.js +38 -0
- package/src/services/diff.js +153 -0
- package/src/services/doctor.js +126 -0
- package/src/services/logger.js +190 -0
- package/src/services/preferences.js +249 -0
- package/src/services/processes.js +345 -0
- package/src/services/provider-settings.js +226 -0
- package/src/services/sessions.js +279 -0
- package/src/services/undo.js +133 -0
- package/src/services/ushell.js +146 -0
- package/src/services/websearch/manager.js +616 -0
- package/src/services/websearch/mcp.js +233 -0
- package/src/services/websearch/normalize.js +284 -0
- package/src/services/websearch/providers.js +178 -0
- package/src/services/websearch/serper.js +263 -0
- package/src/services/workspace.js +589 -0
- package/src/tools/filesystem.js +304 -0
- package/src/tools/git.js +114 -0
- package/src/tools/index.js +48 -0
- package/src/tools/project.js +160 -0
- package/src/tools/registry.js +118 -0
- package/src/tools/search.js +210 -0
- package/src/tools/shell.js +279 -0
- package/src/tools/url.js +542 -0
- package/src/tools/web.js +67 -0
- package/src/utils/errors.js +160 -0
- package/src/utils/network.js +129 -0
- package/src/utils/redaction.js +73 -0
- package/src/utils/validation.js +370 -0
- package/src/utils/version.js +24 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { checkRuntime } from '../config/load.js';
|
|
8
|
+
import { redact } from '../utils/redaction.js';
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
const PASS = 'pass';
|
|
13
|
+
const WARN = 'warn';
|
|
14
|
+
const FAIL = 'fail';
|
|
15
|
+
const SKIP = 'skip';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Diagnostics that never print secrets. With { live: true } it performs one
|
|
19
|
+
* small chat request; otherwise it stays fully offline.
|
|
20
|
+
*/
|
|
21
|
+
export async function runDoctor({ config, workspace, provider = null, live = false, logger = null } = {}) {
|
|
22
|
+
const checks = [];
|
|
23
|
+
const add = (name, status, detail) => checks.push({ name, status, detail });
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const runtime = checkRuntime();
|
|
27
|
+
add('runtime', runtime.major >= 25 ? PASS : WARN, `Node.js ${process.version} (target: Node.js 25.x)`);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
add('runtime', FAIL, error.message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const apiKeyPath = config.apiKeyPath || 'providers.nvidia.apiKey';
|
|
33
|
+
const providerName = config.providerName || 'NVIDIA Integrate API';
|
|
34
|
+
const providerId = config.providerId || 'nvidia';
|
|
35
|
+
add(
|
|
36
|
+
'config',
|
|
37
|
+
config.apiKey ? PASS : FAIL,
|
|
38
|
+
config.apiKey ? `${apiKeyPath} is set (value hidden)` : `${apiKeyPath} is missing`,
|
|
39
|
+
);
|
|
40
|
+
add('provider.endpoint', PASS, `${providerName} (${providerId}) at ${config.baseURL} (OpenAI-compatible chat.completions)`);
|
|
41
|
+
add('provider.model', config.apiKey ? PASS : WARN, config.model);
|
|
42
|
+
const providerRows = Array.isArray(config.providers) ? config.providers : [];
|
|
43
|
+
add(
|
|
44
|
+
'provider.catalog',
|
|
45
|
+
providerRows.length > 0 ? PASS : FAIL,
|
|
46
|
+
providerRows.length > 0
|
|
47
|
+
? providerRows
|
|
48
|
+
.map((provider) => `${provider.id}${provider.id === providerId ? '*' : ''}: ${provider.apiKey ? 'key set' : `no key (${provider.apiKeyPath})`}`)
|
|
49
|
+
.join(' · ')
|
|
50
|
+
: 'no providers configured',
|
|
51
|
+
);
|
|
52
|
+
add(
|
|
53
|
+
'web_search',
|
|
54
|
+
config.webSearchApiKey ? PASS : SKIP,
|
|
55
|
+
config.webSearchApiKey
|
|
56
|
+
? `${config.webSearchEndpoint} (key hidden, ${config.webSearchResults} result cap)`
|
|
57
|
+
: 'Disabled: set webSearch.serperApiKey in config.json to enable the web_search tool.',
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (workspace) {
|
|
61
|
+
try {
|
|
62
|
+
const probeName = `.ryuu/doctor-probe-${crypto.randomBytes(3).toString('hex')}.txt`;
|
|
63
|
+
await workspace.writeText(probeName, 'ryuu doctor probe\n');
|
|
64
|
+
const readBack = await workspace.readText(probeName);
|
|
65
|
+
await workspace.remove(probeName);
|
|
66
|
+
add('workspace.write', readBack.text.startsWith('ryuu doctor probe') ? PASS : FAIL, `Read/write OK in ${workspace.root}`);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
add('workspace.write', FAIL, `Cannot write inside workspace: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
add('workspace.write', FAIL, 'Workspace is not available');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const [label, dir] of [['state.history', config.historyDir], ['state.logs', config.logDir], ['state.undo', config.undoDir]]) {
|
|
75
|
+
try {
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
77
|
+
await fsp.access(dir, fs.constants.W_OK);
|
|
78
|
+
add(label, PASS, dir);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
add(label, FAIL, `${dir} is not writable: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const { stdout } = await execFileAsync('git', ['--version'], { timeout: 5000 });
|
|
86
|
+
add('git', PASS, stdout.trim());
|
|
87
|
+
} catch {
|
|
88
|
+
add('git', WARN, 'Git is not installed; /diff and inspect_git will report unavailable.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!config.apiKey) {
|
|
92
|
+
add('provider.connectivity', SKIP, `Skipped: no API key. Set ${apiKeyPath}, then run /doctor live.`);
|
|
93
|
+
} else if (!live) {
|
|
94
|
+
add('provider.connectivity', SKIP, 'Offline check only. Run /doctor live (or `ryuu --doctor --live`) for one small API call.');
|
|
95
|
+
} else if (!provider) {
|
|
96
|
+
add('provider.connectivity', FAIL, 'Provider is not initialized.');
|
|
97
|
+
} else {
|
|
98
|
+
const result = await provider.ping();
|
|
99
|
+
if (result.ok) {
|
|
100
|
+
add('provider.connectivity', PASS, `${providerName} · ${result.model} responded in ${result.latencyMs}ms`);
|
|
101
|
+
} else {
|
|
102
|
+
add('provider.connectivity', FAIL, redact(`${result.error.code}: ${result.error.message}`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
logger?.info('doctor finished', { ok: !checks.some((check) => check.status === FAIL), live });
|
|
107
|
+
return {
|
|
108
|
+
live,
|
|
109
|
+
ok: !checks.some((check) => check.status === FAIL),
|
|
110
|
+
checks,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function formatDoctor(report) {
|
|
115
|
+
const icon = { pass: 'PASS', warn: 'WARN', fail: 'FAIL', skip: 'SKIP' };
|
|
116
|
+
const lines = ['Ryuu Agent CLI doctor'];
|
|
117
|
+
for (const check of report.checks) {
|
|
118
|
+
lines.push(`[${icon[check.status] || check.status}] ${check.name}: ${check.detail}`);
|
|
119
|
+
}
|
|
120
|
+
lines.push(report.ok ? 'Result: ready (warnings are informational).' : 'Result: blocking issues found.');
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function doctorLogPath(config) {
|
|
125
|
+
return path.join(config.logDir, 'agent.log');
|
|
126
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createRedactor } from '../utils/redaction.js';
|
|
4
|
+
|
|
5
|
+
export const LEVELS = { debug: 10, info: 20, warn: 30, error: 40, silent: 100 };
|
|
6
|
+
|
|
7
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
const MAX_REASONING_CHARS = 2_000_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Leveled logger. Everything written to the log file and to stderr passes
|
|
12
|
+
* through the redactor first. Model output and tool output are logged as
|
|
13
|
+
* bounded previews, never full credential material.
|
|
14
|
+
*/
|
|
15
|
+
export class Logger {
|
|
16
|
+
constructor({ level = 'info', file = null, secrets = [], stderr = null, echo = false, maxPreview = 2000 } = {}) {
|
|
17
|
+
this.level = LEVELS[level] ?? LEVELS.info;
|
|
18
|
+
this.levelName = level;
|
|
19
|
+
this.file = file;
|
|
20
|
+
this.stderr = stderr;
|
|
21
|
+
this.echo = echo;
|
|
22
|
+
this.maxPreview = maxPreview;
|
|
23
|
+
this.redact = createRedactor(secrets);
|
|
24
|
+
this.enabled = this.level < LEVELS.silent;
|
|
25
|
+
if (this.file && this.enabled) {
|
|
26
|
+
try {
|
|
27
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
28
|
+
this.#restrictPermissions();
|
|
29
|
+
this.#rotateIfNeeded();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
this.file = null;
|
|
32
|
+
this.stderr?.write(`[warn] Cannot create log file: ${error.message}\n`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get logFile() {
|
|
38
|
+
return this.file;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setLevel(level) {
|
|
42
|
+
if (LEVELS[level] === undefined) return;
|
|
43
|
+
this.level = LEVELS[level];
|
|
44
|
+
this.levelName = level;
|
|
45
|
+
this.enabled = this.level < LEVELS.silent;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
child(bindings = {}) {
|
|
49
|
+
const logger = Object.create(this);
|
|
50
|
+
logger.bindings = { ...(this.bindings || {}), ...bindings };
|
|
51
|
+
logger.child = Logger.prototype.child;
|
|
52
|
+
logger.log = Logger.prototype.log;
|
|
53
|
+
logger.writeLine = Logger.prototype.writeLine;
|
|
54
|
+
return logger;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
debug(message, meta) {
|
|
58
|
+
this.log('debug', message, meta);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
info(message, meta) {
|
|
62
|
+
this.log('info', message, meta);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
warn(message, meta) {
|
|
66
|
+
this.log('warn', message, meta);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
error(message, meta) {
|
|
70
|
+
this.log('error', message, meta);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
log(level, message, meta) {
|
|
74
|
+
if (LEVELS[level] < this.level) return;
|
|
75
|
+
const entry = {
|
|
76
|
+
ts: new Date().toISOString(),
|
|
77
|
+
level,
|
|
78
|
+
msg: this.#preview(message),
|
|
79
|
+
...(this.bindings || {}),
|
|
80
|
+
};
|
|
81
|
+
if (meta !== undefined) {
|
|
82
|
+
entry.meta = this.#sanitizeMeta(meta);
|
|
83
|
+
}
|
|
84
|
+
this.writeLine(entry);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#preview(value) {
|
|
88
|
+
const text = typeof value === 'string' ? value : safeStringify(value);
|
|
89
|
+
const bounded = text.length > this.maxPreview ? `${text.slice(0, this.maxPreview)}…[truncated]` : text;
|
|
90
|
+
return this.redact(bounded);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#sanitizeMeta(meta) {
|
|
94
|
+
const text = safeStringify(meta);
|
|
95
|
+
const bounded = text.length > this.maxPreview ? `${text.slice(0, this.maxPreview)}…[truncated]` : text;
|
|
96
|
+
const cleaned = this.redact(bounded);
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(cleaned);
|
|
99
|
+
} catch {
|
|
100
|
+
return cleaned;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Persist provider-exposed reasoning separately from the main log. The text
|
|
106
|
+
* is redacted and bounded; the returned path is safe to show in the UI.
|
|
107
|
+
*/
|
|
108
|
+
writeReasoning({ sessionId = 'session', step = null, text = '' } = {}) {
|
|
109
|
+
if (!this.file || !this.enabled || !text) return null;
|
|
110
|
+
const safeId = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80) || 'session';
|
|
111
|
+
const dir = path.join(path.dirname(this.file), 'reasoning');
|
|
112
|
+
const file = path.join(dir, `reasoning-${safeId}.log`);
|
|
113
|
+
const clean = this.redact(String(text));
|
|
114
|
+
const body = clean.length > MAX_REASONING_CHARS
|
|
115
|
+
? `${clean.slice(0, MAX_REASONING_CHARS)}\n…[reasoning truncated at ${MAX_REASONING_CHARS} chars]`
|
|
116
|
+
: clean;
|
|
117
|
+
try {
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
119
|
+
if (process.platform !== 'win32') {
|
|
120
|
+
try {
|
|
121
|
+
fs.chmodSync(dir, 0o700);
|
|
122
|
+
if (fs.existsSync(file)) fs.chmodSync(file, 0o600);
|
|
123
|
+
} catch {
|
|
124
|
+
// Non-fatal; the text is still redacted.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const header = `\n=== step ${step ?? '?'} · ${new Date().toISOString()} ===\n`;
|
|
128
|
+
fs.appendFileSync(file, `${header}${body}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
129
|
+
return file;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
this.stderr?.write(`[warn] Cannot write reasoning log: ${error.message}\n`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
writeLine(entry) {
|
|
137
|
+
const line = this.redact(safeStringify(entry));
|
|
138
|
+
if (this.file && this.enabled) {
|
|
139
|
+
try {
|
|
140
|
+
fs.appendFileSync(this.file, `${line}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
141
|
+
} catch {
|
|
142
|
+
// Logging must never crash the agent; keep stderr usable instead.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (this.echo && this.stderr) {
|
|
146
|
+
this.stderr.write(`${line}\n`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Logs may contain workspace excerpts, so keep them owner-only on POSIX. */
|
|
151
|
+
#restrictPermissions() {
|
|
152
|
+
if (!this.file || process.platform === 'win32') return;
|
|
153
|
+
try {
|
|
154
|
+
fs.chmodSync(path.dirname(this.file), 0o700);
|
|
155
|
+
if (fs.existsSync(this.file)) fs.chmodSync(this.file, 0o600);
|
|
156
|
+
} catch {
|
|
157
|
+
// Non-fatal: the log is still redacted even if chmod is unavailable.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#rotateIfNeeded() {
|
|
162
|
+
if (!this.file || !fs.existsSync(this.file)) return;
|
|
163
|
+
const stat = fs.statSync(this.file);
|
|
164
|
+
if (stat.size < MAX_LOG_BYTES) return;
|
|
165
|
+
const backup = `${this.file}.1`;
|
|
166
|
+
fs.rmSync(backup, { force: true });
|
|
167
|
+
fs.renameSync(this.file, backup);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createLogger(config, { stderr = process.stderr, secrets = [], echo = false } = {}) {
|
|
172
|
+
return new Logger({
|
|
173
|
+
level: config.logLevel,
|
|
174
|
+
file: config.logDir ? path.join(config.logDir, 'agent.log') : null,
|
|
175
|
+
secrets: [config.apiKey, ...secrets].filter(Boolean),
|
|
176
|
+
stderr,
|
|
177
|
+
echo,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function safeStringify(value) {
|
|
182
|
+
if (value instanceof Error) {
|
|
183
|
+
return JSON.stringify({ name: value.name, message: value.message, code: value.code, stack: value.stack });
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
return JSON.stringify(value, (key, item) => (typeof item === 'bigint' ? item.toString() : item));
|
|
187
|
+
} catch {
|
|
188
|
+
return String(value);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_PROVIDER_ID,
|
|
5
|
+
MODEL_ID_PATTERN,
|
|
6
|
+
MAX_MODEL_ID_LENGTH,
|
|
7
|
+
normalizeModelId,
|
|
8
|
+
normalizeProviderId,
|
|
9
|
+
} from '../providers/catalog.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* User preferences that survive CLI restarts: the active provider, the active
|
|
13
|
+
* model, and the model ids added with `/addmodel`.
|
|
14
|
+
*
|
|
15
|
+
* The state lives in the workspace's `.ryuu` folder next to sessions, logs,
|
|
16
|
+
* undo and cache, so the choice follows the project the CLI is run from.
|
|
17
|
+
* Values are validated before being read back, so a hand-edited or corrupt
|
|
18
|
+
* file can never put an invalid provider/model id into the config.
|
|
19
|
+
*
|
|
20
|
+
* Shape (all keys optional):
|
|
21
|
+
* {
|
|
22
|
+
* "provider": "tokenharbor",
|
|
23
|
+
* "model": "deepseek-v4.1-flash:free",
|
|
24
|
+
* "providerModel": { "nvidia": "…", "tokenharbor": "…" },
|
|
25
|
+
* "providerModels": { "nvidia": ["added-id"] },
|
|
26
|
+
* "models": ["legacy ids, read as the active provider's list"]
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Upper bound on user-added models, so the list cannot grow without limit. */
|
|
31
|
+
export const MAX_CUSTOM_MODELS = 100;
|
|
32
|
+
export { MAX_MODEL_ID_LENGTH, MODEL_ID_PATTERN, normalizeModelId };
|
|
33
|
+
|
|
34
|
+
/** Root of the local state folder for a workspace. */
|
|
35
|
+
export function stateDirFor(workspace) {
|
|
36
|
+
return path.join(workspace || '.', '.ryuu');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function preferencesFile(dir) {
|
|
40
|
+
return path.join(dir, 'preferences.json');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read the whole preferences object; {} when absent or corrupt. Never throws. */
|
|
44
|
+
export async function readPreferences(dir) {
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.parse(await fsp.readFile(preferencesFile(dir), 'utf8'));
|
|
47
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
48
|
+
} catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Merge a patch into preferences.json. Other keys are preserved, so adding or
|
|
55
|
+
* removing a model never forgets the selected model, and switching a model
|
|
56
|
+
* never forgets the added ones.
|
|
57
|
+
*/
|
|
58
|
+
async function writePreferences(dir, patch) {
|
|
59
|
+
try {
|
|
60
|
+
const payload = {
|
|
61
|
+
...(await readPreferences(dir)),
|
|
62
|
+
...patch,
|
|
63
|
+
updatedAt: new Date().toISOString(),
|
|
64
|
+
};
|
|
65
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
66
|
+
await fsp.writeFile(preferencesFile(dir), `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeModelList(raw) {
|
|
74
|
+
if (!Array.isArray(raw)) return [];
|
|
75
|
+
const models = [];
|
|
76
|
+
for (const entry of raw) {
|
|
77
|
+
const model = normalizeModelId(entry);
|
|
78
|
+
if (model && !models.includes(model)) models.push(model);
|
|
79
|
+
if (models.length >= MAX_CUSTOM_MODELS) break;
|
|
80
|
+
}
|
|
81
|
+
return models;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A validated `provider -> [model ids]` map (unknown entries skipped). */
|
|
85
|
+
function modelListMap(raw) {
|
|
86
|
+
const map = {};
|
|
87
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return map;
|
|
88
|
+
for (const [id, list] of Object.entries(raw)) {
|
|
89
|
+
const providerId = normalizeProviderId(id);
|
|
90
|
+
if (providerId) map[providerId] = normalizeModelList(list);
|
|
91
|
+
}
|
|
92
|
+
return map;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A validated `provider -> model id` map. */
|
|
96
|
+
function modelChoiceMap(raw) {
|
|
97
|
+
const map = {};
|
|
98
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return map;
|
|
99
|
+
for (const [id, model] of Object.entries(raw)) {
|
|
100
|
+
const providerId = normalizeProviderId(id);
|
|
101
|
+
const normalized = normalizeModelId(model);
|
|
102
|
+
if (providerId && normalized) map[providerId] = normalized;
|
|
103
|
+
}
|
|
104
|
+
return map;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Read the persisted model, or null when absent/invalid. Never throws. */
|
|
108
|
+
export async function readModelPreference(dir) {
|
|
109
|
+
return normalizeModelId((await readPreferences(dir)).model);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Persist the model choice. With a provider id the choice is also recorded for
|
|
114
|
+
* that provider, so switching providers remembers each one's last model.
|
|
115
|
+
* Returns false (no throw) for invalid ids.
|
|
116
|
+
*/
|
|
117
|
+
export async function writeModelPreference(dir, model, { providerId = null } = {}) {
|
|
118
|
+
const id = normalizeModelId(model);
|
|
119
|
+
if (!id) return false;
|
|
120
|
+
const provider = normalizeProviderId(providerId);
|
|
121
|
+
if (!provider) return writePreferences(dir, { model: id });
|
|
122
|
+
const prefs = await readPreferences(dir);
|
|
123
|
+
return writePreferences(dir, {
|
|
124
|
+
model: id,
|
|
125
|
+
providerModel: { ...modelChoiceMap(prefs.providerModel), [provider]: id },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Last model chosen for a provider (falls back to the legacy top-level value). */
|
|
130
|
+
export async function readProviderModel(dir, providerId) {
|
|
131
|
+
const provider = normalizeProviderId(providerId);
|
|
132
|
+
if (!provider) return null;
|
|
133
|
+
const prefs = await readPreferences(dir);
|
|
134
|
+
const choices = modelChoiceMap(prefs.providerModel);
|
|
135
|
+
if (choices[provider]) return choices[provider];
|
|
136
|
+
const hasChoices = Object.keys(choices).length > 0;
|
|
137
|
+
// Backwards compatibility: files written before per-provider models existed
|
|
138
|
+
// stored one top-level `model` for the then-active provider.
|
|
139
|
+
if (!hasChoices) {
|
|
140
|
+
const active = normalizeProviderId(prefs.provider) || DEFAULT_PROVIDER_ID;
|
|
141
|
+
if (provider === active) return normalizeModelId(prefs.model);
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Persist a model choice for one provider without changing the active model. */
|
|
147
|
+
export async function writeProviderModel(dir, providerId, model) {
|
|
148
|
+
const provider = normalizeProviderId(providerId);
|
|
149
|
+
const id = normalizeModelId(model);
|
|
150
|
+
if (!provider || !id) return false;
|
|
151
|
+
const prefs = await readPreferences(dir);
|
|
152
|
+
return writePreferences(dir, {
|
|
153
|
+
providerModel: { ...modelChoiceMap(prefs.providerModel), [provider]: id },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Read the persisted provider id, or null when absent/invalid. Never throws. */
|
|
158
|
+
export async function readProviderPreference(dir) {
|
|
159
|
+
return normalizeProviderId((await readPreferences(dir)).provider);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Persist the active provider id. Returns false (no throw) for invalid ids. */
|
|
163
|
+
export async function writeProviderPreference(dir, providerId) {
|
|
164
|
+
const id = normalizeProviderId(providerId);
|
|
165
|
+
if (!id) return false;
|
|
166
|
+
return writePreferences(dir, { provider: id });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The ids added with `/addmodel` for one provider, in insertion order,
|
|
171
|
+
* de-duplicated and validated. A corrupt entry is skipped instead of failing
|
|
172
|
+
* the whole list. Without a provider id the legacy shared list is returned.
|
|
173
|
+
*/
|
|
174
|
+
export async function readCustomModels(dir, providerId = null) {
|
|
175
|
+
const prefs = await readPreferences(dir);
|
|
176
|
+
const legacy = normalizeModelList(prefs.models);
|
|
177
|
+
const provider = normalizeProviderId(providerId);
|
|
178
|
+
if (!provider) return legacy;
|
|
179
|
+
const map = modelListMap(prefs.providerModels);
|
|
180
|
+
if (Array.isArray(map[provider])) return map[provider];
|
|
181
|
+
const active = normalizeProviderId(prefs.provider) || DEFAULT_PROVIDER_ID;
|
|
182
|
+
return provider === active ? legacy : [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function writeCustomModels(dir, providerId, models) {
|
|
186
|
+
const provider = normalizeProviderId(providerId);
|
|
187
|
+
if (!provider) return writePreferences(dir, { models });
|
|
188
|
+
const prefs = await readPreferences(dir);
|
|
189
|
+
return writePreferences(dir, {
|
|
190
|
+
providerModels: { ...modelListMap(prefs.providerModels), [provider]: models },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Add an id to a provider's saved model list. Returns { ok, reason, models };
|
|
196
|
+
* reason is 'invalid' | 'exists' | 'limit' | 'write' when ok is false.
|
|
197
|
+
*/
|
|
198
|
+
export async function addCustomModel(dir, id, providerId = null) {
|
|
199
|
+
const models = await readCustomModels(dir, providerId);
|
|
200
|
+
const model = normalizeModelId(id);
|
|
201
|
+
if (!model) return { ok: false, reason: 'invalid', models };
|
|
202
|
+
if (models.includes(model)) return { ok: false, reason: 'exists', models };
|
|
203
|
+
if (models.length >= MAX_CUSTOM_MODELS) return { ok: false, reason: 'limit', models };
|
|
204
|
+
const next = [...models, model];
|
|
205
|
+
const saved = await writeCustomModels(dir, providerId, next);
|
|
206
|
+
return saved ? { ok: true, reason: null, models: next } : { ok: false, reason: 'write', models };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Remove an id from a provider's saved model list. Returns { ok, reason,
|
|
211
|
+
* models }; reason is 'missing' | 'write' when ok is false.
|
|
212
|
+
*/
|
|
213
|
+
export async function removeCustomModel(dir, id, providerId = null) {
|
|
214
|
+
const models = await readCustomModels(dir, providerId);
|
|
215
|
+
const model = normalizeModelId(id);
|
|
216
|
+
if (!model || !models.includes(model)) return { ok: false, reason: 'missing', models };
|
|
217
|
+
const next = models.filter((entry) => entry !== model);
|
|
218
|
+
const saved = await writeCustomModels(dir, providerId, next);
|
|
219
|
+
return saved ? { ok: true, reason: null, models: next } : { ok: false, reason: 'write', models };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Apply the persisted `/provider` choice at startup. An explicit flag or a
|
|
224
|
+
* `provider` key in config.json wins and is never overwritten. Returns true when raw.provider
|
|
225
|
+
* was changed.
|
|
226
|
+
*/
|
|
227
|
+
export async function applyPersistedProvider(raw, { flagProvider = null, stateDir = null } = {}) {
|
|
228
|
+
if (flagProvider || raw.provider || !stateDir) return false;
|
|
229
|
+
const persisted = await readProviderPreference(stateDir);
|
|
230
|
+
if (!persisted) return false;
|
|
231
|
+
raw.provider = persisted;
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Apply the persisted model at startup.
|
|
237
|
+
* Precedence: explicit --model flag (this run only) > persisted choice for the
|
|
238
|
+
* selected provider > the provider's own model variable > catalog default.
|
|
239
|
+
* Returns true when raw.model was changed.
|
|
240
|
+
*/
|
|
241
|
+
export async function applyPersistedModel(raw, { flagModel = null, stateDir = null, providerId = null } = {}) {
|
|
242
|
+
if (flagModel || !stateDir) return false;
|
|
243
|
+
const persisted = providerId
|
|
244
|
+
? await readProviderModel(stateDir, providerId)
|
|
245
|
+
: await readModelPreference(stateDir);
|
|
246
|
+
if (!persisted || persisted === raw.model) return false;
|
|
247
|
+
raw.model = persisted;
|
|
248
|
+
return true;
|
|
249
|
+
}
|