sphinx-understand 0.1.0 → 0.1.2
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 +106 -273
- package/docs/architecture.md +16 -0
- package/docs/configuration.md +241 -0
- package/docs/integrations.md +135 -0
- package/docs/logo.svg +3 -0
- package/package.json +4 -2
- package/scripts/postinstall.js +23 -0
- package/src/browser-interview.js +74 -8
- package/src/browser-page.js +287 -32
- package/src/change-view.js +314 -0
- package/src/cli.js +57 -20
- package/src/config.js +57 -5
- package/src/evaluator.js +31 -5
- package/src/help.js +5 -4
- package/src/install.js +3 -1
- package/src/logo.js +6 -0
- package/src/mcp.js +2 -1
- package/src/openrouter.js +65 -35
- package/src/overrides.js +16 -5
- package/src/preparation.js +19 -3
- package/src/prompts.js +25 -1
- package/src/review-workspace.js +13 -10
- package/src/reviewer.js +9 -4
- package/src/routing.js +3 -1
- package/src/sessions.js +10 -1
- package/src/stream.js +59 -0
- package/src/terminal.js +16 -14
- package/src/version.js +4 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { loadIgnorePolicy } from './ignore.js';
|
|
6
|
+
import { workspaceDiffText } from './review-workspace.js';
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const OID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u;
|
|
10
|
+
const REGULAR = new Set(['100644', '100755']);
|
|
11
|
+
// Display limits only. The reviewer's own evidence limits are unchanged.
|
|
12
|
+
export const VIEW_LIMITS = Object.freeze({ files: 2000, rows: 3000, bytes: 2 * 1024 * 1024, evidenceBytes: 8 * 1024 * 1024 });
|
|
13
|
+
export const CONTEXT_LEVELS = Object.freeze({ 3: 3, 25: 25, all: 1000000 });
|
|
14
|
+
|
|
15
|
+
class ChangeViewError extends Error {
|
|
16
|
+
constructor(message) { super(message); this.code = 'CHANGE_VIEW_UNAVAILABLE'; }
|
|
17
|
+
}
|
|
18
|
+
const unavailable = message => { throw new ChangeViewError(message); };
|
|
19
|
+
const decoder = () => new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
20
|
+
|
|
21
|
+
async function git(root, args, { env, buffer = false, maxBuffer = 16 * 1024 * 1024 } = {}) {
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await exec('git', ['--no-pager', '--literal-pathspecs', '-c', 'core.fsmonitor=false', ...args], {
|
|
24
|
+
cwd: root, encoding: buffer ? 'buffer' : 'utf8', maxBuffer, timeout: 30000,
|
|
25
|
+
env: { ...process.env, ...env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_TERMINAL_PROMPT: '0' },
|
|
26
|
+
});
|
|
27
|
+
return stdout;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') return null;
|
|
30
|
+
unavailable('The checkpoint’s Git objects are unavailable.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function treeEntries(raw) {
|
|
35
|
+
const entries = new Map();
|
|
36
|
+
for (const row of raw.split('\0').filter(Boolean)) {
|
|
37
|
+
const match = /^(\d{6}) (?:blob|tree|commit) ([a-f0-9]+)\t([\s\S]*)$/u.exec(row);
|
|
38
|
+
if (!match || !OID.test(match[2])) unavailable('The checkpoint inventory is malformed.');
|
|
39
|
+
entries.set(match[3], { mode: match[1], oid: match[2] });
|
|
40
|
+
}
|
|
41
|
+
return entries;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// `git diff --raw -z -M`: rename rows carry two paths.
|
|
45
|
+
function rawRows(raw) {
|
|
46
|
+
const fields = raw.split('\0'), rows = [];
|
|
47
|
+
for (let index = 0; index < fields.length - 1;) {
|
|
48
|
+
const match = /^:(\d{6}) (\d{6}) ([a-f0-9]+) ([a-f0-9]+) ([A-Z])\d*$/u.exec(fields[index++]);
|
|
49
|
+
if (!match) unavailable('The checkpoint comparison is malformed.');
|
|
50
|
+
const [, oldMode, newMode, oldOid, newOid, status] = match;
|
|
51
|
+
const first = fields[index++], second = status === 'R' || status === 'C' ? fields[index++] : undefined;
|
|
52
|
+
if (!first || (second === '' )) unavailable('The checkpoint comparison is malformed.');
|
|
53
|
+
rows.push({ status, oldMode, newMode, oldOid, newOid, oldPath: first, newPath: second ?? first });
|
|
54
|
+
}
|
|
55
|
+
return rows;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// `git diff --numstat -z -M`: renames use an empty path field followed by both paths.
|
|
59
|
+
function numstatRows(raw) {
|
|
60
|
+
const fields = raw.split('\0'), stats = new Map();
|
|
61
|
+
for (let index = 0; index < fields.length - 1;) {
|
|
62
|
+
const row = fields[index++], first = row.indexOf('\t'), second = row.indexOf('\t', first + 1);
|
|
63
|
+
if (first < 0 || second < 0) unavailable('The checkpoint line counts are malformed.');
|
|
64
|
+
const added = row.slice(0, first), deleted = row.slice(first + 1, second);
|
|
65
|
+
let path = row.slice(second + 1);
|
|
66
|
+
if (!path) { index++; path = fields[index++]; }
|
|
67
|
+
const binary = added === '-' || deleted === '-';
|
|
68
|
+
stats.set(path, binary ? { binary: true, additions: null, deletions: null } : { binary: false, additions: Number(added), deletions: Number(deleted) });
|
|
69
|
+
}
|
|
70
|
+
return stats;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const kind = mode => !mode || /^0+$/u.test(mode) ? null : REGULAR.has(mode) ? 'file' : mode === '120000' ? 'symlink' : mode === '160000' ? 'submodule' : 'other';
|
|
74
|
+
|
|
75
|
+
/** Parse unified diff text into hunks with old/new line numbers. */
|
|
76
|
+
export function parseHunks(text) {
|
|
77
|
+
const hunks = [];
|
|
78
|
+
let hunk, oldLine = 0, newLine = 0;
|
|
79
|
+
for (const line of text.split('\n')) {
|
|
80
|
+
const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/u.exec(line);
|
|
81
|
+
if (header) {
|
|
82
|
+
hunk = { oldStart: Number(header[1]), oldLines: header[2] === undefined ? 1 : Number(header[2]), newStart: Number(header[3]), newLines: header[4] === undefined ? 1 : Number(header[4]), section: header[5].trim(), rows: [] };
|
|
83
|
+
hunks.push(hunk); oldLine = hunk.oldStart; newLine = hunk.newStart; continue;
|
|
84
|
+
}
|
|
85
|
+
if (!hunk || !line) continue;
|
|
86
|
+
const sign = line[0], body = line.slice(1);
|
|
87
|
+
if (sign === ' ') hunk.rows.push({ type: 'context', old: oldLine++, new: newLine++, text: body });
|
|
88
|
+
else if (sign === '-') hunk.rows.push({ type: 'deletion', old: oldLine++, new: null, text: body });
|
|
89
|
+
else if (sign === '+') hunk.rows.push({ type: 'addition', old: null, new: newLine++, text: body });
|
|
90
|
+
else if (sign === '\\' && hunk.rows.length) hunk.rows.at(-1).noNewline = true;
|
|
91
|
+
}
|
|
92
|
+
return hunks;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function wholeFileHunk(text, side) {
|
|
96
|
+
const lines = text.split('\n'), noNewline = text.length > 0 && !text.endsWith('\n');
|
|
97
|
+
if (!noNewline) lines.pop();
|
|
98
|
+
if (!lines.length) return [];
|
|
99
|
+
const rows = lines.map((line, index) => side === 'new'
|
|
100
|
+
? { type: 'addition', old: null, new: index + 1, text: line }
|
|
101
|
+
: { type: 'deletion', old: index + 1, new: null, text: line });
|
|
102
|
+
if (noNewline) rows.at(-1).noNewline = true;
|
|
103
|
+
return [side === 'new'
|
|
104
|
+
? { oldStart: 0, oldLines: 0, newStart: 1, newLines: rows.length, section: '', rows }
|
|
105
|
+
: { oldStart: 1, oldLines: rows.length, newStart: 0, newLines: 0, section: '', rows }];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function mergeRanges(ranges) {
|
|
109
|
+
const merged = [];
|
|
110
|
+
for (const range of [...ranges].sort((a, b) => a.side.localeCompare(b.side) || a.start - b.start)) {
|
|
111
|
+
const last = merged.at(-1);
|
|
112
|
+
if (last && last.side === range.side && range.start <= last.end + 1) last.end = Math.max(last.end, range.end);
|
|
113
|
+
else merged.push({ ...range });
|
|
114
|
+
}
|
|
115
|
+
return merged;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A display model of exactly one frozen checkpoint comparison. Every byte comes
|
|
119
|
+
* from immutable blob IDs recorded for the checkpoint, never the working tree. */
|
|
120
|
+
export async function loadChangeView(snapshot, config, { env = process.env } = {}) {
|
|
121
|
+
const root = snapshot?.repoRoot;
|
|
122
|
+
if (typeof root !== 'string' || !Array.isArray(snapshot.files)) unavailable('This checkpoint has no recorded file inventory.');
|
|
123
|
+
if (snapshot.base && !OID.test(snapshot.base)) unavailable('The checkpoint base is not an immutable Git object.');
|
|
124
|
+
const commit = snapshot.event === 'pre-commit';
|
|
125
|
+
if (commit && !Array.isArray(snapshot.indexEntries)) unavailable('This older commit checkpoint did not record its staged file inventory.');
|
|
126
|
+
if (!commit && !OID.test(snapshot.head || '')) unavailable('The checkpoint head is not an immutable Git object.');
|
|
127
|
+
const run = (args, options = {}) => git(root, args, { ...options, env });
|
|
128
|
+
const ignore = await loadIgnorePolicy(root, { env, exclude: config.exclude });
|
|
129
|
+
if (snapshot.ignoreHash && ignore.hash !== snapshot.ignoreHash) unavailable('The exclusion rules changed after this checkpoint was created.');
|
|
130
|
+
const format = (await run(['rev-parse', '--show-object-format']))?.trim();
|
|
131
|
+
const emptyTree = createHash(format === 'sha256' ? 'sha256' : 'sha1').update('tree 0\0').digest('hex');
|
|
132
|
+
const base = snapshot.base ? treeEntries(await run(['ls-tree', '-r', '-z', '--full-tree', snapshot.base]) ?? '') : new Map();
|
|
133
|
+
const head = commit
|
|
134
|
+
? new Map(snapshot.indexEntries.map(entry => [entry.path, { mode: entry.mode, oid: entry.oid }]))
|
|
135
|
+
: treeEntries(await run(['ls-tree', '-r', '-z', '--full-tree', snapshot.head]) ?? '');
|
|
136
|
+
const comparison = ['diff', '--no-ext-diff', '--no-textconv', '--no-color', '-M', ...(commit ? ['--cached', snapshot.base || emptyTree] : [snapshot.base || emptyTree, snapshot.head])];
|
|
137
|
+
const raw = await run([...comparison, '--raw', '-z', '--no-abbrev', '--']);
|
|
138
|
+
const numstat = await run([...comparison, '--numstat', '-z', '--']);
|
|
139
|
+
if (raw === null || numstat === null) unavailable('The checkpoint comparison is too large to display.');
|
|
140
|
+
// A commit checkpoint compares against the live index. Accept it only when
|
|
141
|
+
// it still matches the pinned index entries and did not move mid-read.
|
|
142
|
+
if (commit && (await run([...comparison, '--raw', '-z', '--no-abbrev', '--'])) !== raw) unavailable('The staged changes moved while loading the checkpoint.');
|
|
143
|
+
const stats = numstatRows(numstat), files = [], seen = new Set();
|
|
144
|
+
const allowed = path => !ignore.matches(path);
|
|
145
|
+
for (const row of rawRows(raw)) {
|
|
146
|
+
const oldSide = row.status === 'A' ? null : { path: row.oldPath, mode: row.oldMode, oid: row.oldOid };
|
|
147
|
+
const newSide = row.status === 'D' ? null : { path: row.newPath, mode: row.newMode, oid: row.newOid };
|
|
148
|
+
for (const [side, tree] of [[oldSide, base], [newSide, head]]) {
|
|
149
|
+
if (side && allowed(side.path) && tree.get(side.path)?.oid !== side.oid) unavailable('The checkpoint comparison no longer matches its recorded Git objects.');
|
|
150
|
+
}
|
|
151
|
+
let from = oldSide && allowed(oldSide.path) ? oldSide : null, to = newSide && allowed(newSide.path) ? newSide : null;
|
|
152
|
+
if (!from && !to) continue;
|
|
153
|
+
// A rename across an exclusion boundary must not reveal the excluded name.
|
|
154
|
+
const split = row.status === 'R' && (!from || !to);
|
|
155
|
+
if (from) seen.add(from.path);
|
|
156
|
+
if (to) seen.add(to.path);
|
|
157
|
+
const status = from && to ? (row.status === 'R' ? 'renamed' : 'modified') : to ? 'added' : 'deleted';
|
|
158
|
+
const counted = split ? { binary: false, additions: null, deletions: null } : stats.get((to || from).path) ?? { binary: false, additions: null, deletions: null };
|
|
159
|
+
files.push({
|
|
160
|
+
path: (to || from).path, ...(status === 'renamed' ? { oldPath: from.path } : {}), status,
|
|
161
|
+
binary: counted.binary, additions: counted.additions, deletions: counted.deletions,
|
|
162
|
+
oldKind: kind(from?.mode), newKind: kind(to?.mode),
|
|
163
|
+
...(from && to && from.mode !== to.mode ? { modeChange: { from: from.mode, to: to.mode } } : {}),
|
|
164
|
+
oldOid: from?.oid ?? null, newOid: to?.oid ?? null,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const recorded = new Set(snapshot.files);
|
|
168
|
+
if (seen.size !== recorded.size || [...seen].some(path => !recorded.has(path))) unavailable('The displayed comparison does not match the reviewed file list.');
|
|
169
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
170
|
+
const byPath = new Map(files.map(file => [file.path, file]));
|
|
171
|
+
const totals = summarize(files);
|
|
172
|
+
const header = {
|
|
173
|
+
checkpoint: snapshot.id, repository: basename(root), event: snapshot.event,
|
|
174
|
+
remote: snapshot.target?.remote ?? null, destination: snapshot.target?.ref ?? null,
|
|
175
|
+
base: snapshot.base ?? null, head: commit ? null : snapshot.head, staged: commit,
|
|
176
|
+
excludedFiles: Array.isArray(snapshot.omitted) ? snapshot.omitted.length : 0,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
async function blobText(oid) {
|
|
180
|
+
const body = await run(['cat-file', 'blob', oid], { buffer: true, maxBuffer: VIEW_LIMITS.bytes });
|
|
181
|
+
if (body === null) return { note: 'too-large' };
|
|
182
|
+
if (body.includes(0)) return { note: 'binary' };
|
|
183
|
+
try { return { text: decoder().decode(body) }; } catch { return { note: 'non-utf8' }; }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function diff(path, { context = 3 } = {}) {
|
|
187
|
+
const file = byPath.get(path);
|
|
188
|
+
if (!file) unavailable('That file is not part of this checkpoint.');
|
|
189
|
+
const level = Object.hasOwn(CONTEXT_LEVELS, context) ? context : 3;
|
|
190
|
+
const result = { path: file.path, ...(file.oldPath ? { oldPath: file.oldPath } : {}), status: file.status, binary: file.binary, additions: file.additions, deletions: file.deletions, ...(file.modeChange ? { modeChange: file.modeChange } : {}), context: String(level), hunks: [], truncated: null, note: null };
|
|
191
|
+
const kinds = [file.oldKind, file.newKind].filter(Boolean);
|
|
192
|
+
if (file.binary) return { ...result, note: 'binary' };
|
|
193
|
+
if (kinds.some(value => value !== 'file')) return { ...result, note: kinds.includes('submodule') ? 'submodule' : kinds.includes('symlink') ? 'symlink' : 'unsupported' };
|
|
194
|
+
let hunks;
|
|
195
|
+
if (file.oldOid && file.newOid) {
|
|
196
|
+
if (file.oldOid === file.newOid) return { ...result, note: file.status === 'renamed' ? 'rename-only' : 'mode-only' };
|
|
197
|
+
const body = await run(['diff', '--no-ext-diff', '--no-textconv', '--no-color', '--full-index', `--unified=${CONTEXT_LEVELS[level]}`, file.oldOid, file.newOid, '--'], { buffer: true, maxBuffer: VIEW_LIMITS.bytes });
|
|
198
|
+
if (body === null) return { ...result, note: 'too-large' };
|
|
199
|
+
let text;
|
|
200
|
+
try { text = decoder().decode(body); } catch { return { ...result, note: 'non-utf8' }; }
|
|
201
|
+
hunks = parseHunks(text);
|
|
202
|
+
} else {
|
|
203
|
+
const blob = await blobText(file.newOid || file.oldOid);
|
|
204
|
+
if (blob.note) return { ...result, note: blob.note };
|
|
205
|
+
hunks = wholeFileHunk(blob.text, file.newOid ? 'new' : 'old');
|
|
206
|
+
if (!hunks.length) return { ...result, note: 'empty' };
|
|
207
|
+
}
|
|
208
|
+
const total = hunks.reduce((sum, hunk) => sum + hunk.rows.length, 0);
|
|
209
|
+
if (total > VIEW_LIMITS.rows) {
|
|
210
|
+
let remaining = VIEW_LIMITS.rows;
|
|
211
|
+
hunks = hunks.filter(() => remaining > 0).map(hunk => {
|
|
212
|
+
const rows = hunk.rows.slice(0, remaining); remaining -= rows.length;
|
|
213
|
+
return { ...hunk, rows, ...(rows.length < hunk.rows.length ? { cut: true } : {}) };
|
|
214
|
+
});
|
|
215
|
+
result.truncated = { shownRows: VIEW_LIMITS.rows, totalRows: total };
|
|
216
|
+
}
|
|
217
|
+
return { ...result, hunks };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Map one retained read_diff page back to file lines, after checking that its
|
|
221
|
+
// exact bytes still occur at the recorded offset of the regenerated text.
|
|
222
|
+
const diffTexts = new Map();
|
|
223
|
+
async function workspaceText(path) {
|
|
224
|
+
if (diffTexts.has(path)) return diffTexts.get(path);
|
|
225
|
+
const chunks = []; let size = 0, text = null;
|
|
226
|
+
try {
|
|
227
|
+
for await (const chunk of workspaceDiffText(root, path, base.get(path), head.get(path), env)) {
|
|
228
|
+
size += chunk.length; if (size > VIEW_LIMITS.evidenceBytes) throw new Error('too large');
|
|
229
|
+
chunks.push(chunk);
|
|
230
|
+
}
|
|
231
|
+
text = Buffer.concat(chunks);
|
|
232
|
+
} catch { text = null; }
|
|
233
|
+
diffTexts.set(path, text); return text;
|
|
234
|
+
}
|
|
235
|
+
function diffLineMap(buffer, synthetic) {
|
|
236
|
+
const lines = [], starts = [];
|
|
237
|
+
let at = 0, oldLine = 0, newLine = 0, inHunk = synthetic, header = synthetic ? 0 : -1;
|
|
238
|
+
const text = buffer.toString('utf8').split('\n');
|
|
239
|
+
for (const line of text) {
|
|
240
|
+
starts.push(at); at += Buffer.byteLength(line) + 1;
|
|
241
|
+
let mapped = null;
|
|
242
|
+
if (line.startsWith('File modes: ') && lines.length === 0) { lines.push(null); continue; }
|
|
243
|
+
if (synthetic && header < 2) { header++; lines.push(null); if (header === 2) { oldLine = 1; newLine = 1; } continue; }
|
|
244
|
+
const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/u.exec(line);
|
|
245
|
+
if (!synthetic && hunk) { inHunk = true; oldLine = Number(hunk[1]); newLine = Number(hunk[2]); lines.push(null); continue; }
|
|
246
|
+
if (inHunk && line) {
|
|
247
|
+
if (line[0] === ' ') mapped = { side: 'new', line: newLine++, oldLine: oldLine++ };
|
|
248
|
+
else if (line[0] === '+') mapped = { side: 'new', line: newLine++ };
|
|
249
|
+
else if (line[0] === '-') mapped = { side: 'old', line: oldLine++ };
|
|
250
|
+
}
|
|
251
|
+
lines.push(mapped);
|
|
252
|
+
}
|
|
253
|
+
return { lines, starts };
|
|
254
|
+
}
|
|
255
|
+
async function diffRanges(path, observation) {
|
|
256
|
+
const result = observation.result, before = base.get(path), after = head.get(path);
|
|
257
|
+
if (result.path !== path || result.binary || typeof result.text !== 'string' || !Number.isSafeInteger(result.offset)) return [];
|
|
258
|
+
if ((result.baseOid ?? null) !== (before?.oid ?? null) || (result.headOid ?? null) !== (after?.oid ?? null)) return [];
|
|
259
|
+
const buffer = await workspaceText(path);
|
|
260
|
+
if (!buffer) return [];
|
|
261
|
+
const observed = Buffer.from(result.text);
|
|
262
|
+
if (!buffer.subarray(result.offset, result.offset + observed.length).equals(observed)) return [];
|
|
263
|
+
const { lines, starts } = diffLineMap(buffer, !(before && after));
|
|
264
|
+
const end = result.offset + observed.length, ranges = [];
|
|
265
|
+
for (let index = 0; index < lines.length; index++) {
|
|
266
|
+
const lineEnd = index + 1 < starts.length ? starts[index + 1] : buffer.length;
|
|
267
|
+
if (!lines[index] || lineEnd <= result.offset || starts[index] >= end) continue;
|
|
268
|
+
ranges.push({ side: lines[index].side, start: lines[index].line, end: lines[index].line });
|
|
269
|
+
}
|
|
270
|
+
return ranges;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Verified locations for one question: only exact observations whose blob
|
|
274
|
+
* IDs match this checkpoint become line ranges. Model text never does. */
|
|
275
|
+
async function evidenceFor(question, review) {
|
|
276
|
+
const cited = [...new Set((question?.evidence ?? []).map(item => item?.path).filter(path => typeof path === 'string' && allowed(path)))];
|
|
277
|
+
const observations = review?.snapshotId === snapshot.id && Array.isArray(review.evidence) ? review.evidence : [];
|
|
278
|
+
const refs = [];
|
|
279
|
+
for (const path of cited) {
|
|
280
|
+
const ranges = [];
|
|
281
|
+
for (const item of observations) {
|
|
282
|
+
if (!Array.isArray(item?.paths) || !item.paths.includes(path) || !item.result || typeof item.result !== 'object') continue;
|
|
283
|
+
if (item.tool === 'read_file') {
|
|
284
|
+
const side = item.result.revision === 'base' ? 'old' : 'new', entry = (side === 'old' ? base : head).get(path);
|
|
285
|
+
if (item.result.path === path && entry && item.result.oid === entry.oid && Number.isSafeInteger(item.result.startLine) && Number.isSafeInteger(item.result.endLine) && item.result.endLine >= item.result.startLine) ranges.push({ side, start: item.result.startLine, end: item.result.endLine });
|
|
286
|
+
} else if (item.tool === 'search' && Array.isArray(item.result.matches)) {
|
|
287
|
+
for (const match of item.result.matches) {
|
|
288
|
+
const side = match?.revision === 'base' ? 'old' : 'new', entry = (side === 'old' ? base : head).get(path);
|
|
289
|
+
if (match?.path === path && entry && match.oid === entry.oid && Number.isSafeInteger(match.line)) ranges.push({ side, start: match.line, end: match.line });
|
|
290
|
+
}
|
|
291
|
+
} else if (item.tool === 'read_diff') ranges.push(...await diffRanges(path, item));
|
|
292
|
+
}
|
|
293
|
+
const file = byPath.get(path) ?? files.find(candidate => candidate.oldPath === path);
|
|
294
|
+
refs.push({ path, inChange: Boolean(file), ...(file ? { viewPath: file.path } : {}), ranges: mergeRanges(ranges) });
|
|
295
|
+
}
|
|
296
|
+
return refs;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
header, totals,
|
|
301
|
+
summary: () => ({ header, totals, files: files.slice(0, VIEW_LIMITS.files).map(({ oldOid, newOid, oldKind, newKind, ...file }) => ({ ...file, ...(oldKind && oldKind !== 'file' || newKind && newKind !== 'file' ? { special: newKind !== 'file' ? newKind : oldKind } : {}) })), listed: Math.min(files.length, VIEW_LIMITS.files) }),
|
|
302
|
+
diff, evidenceFor,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function summarize(files) {
|
|
307
|
+
const totals = { files: files.length, additions: 0, deletions: 0, binaryFiles: 0, uncountedFiles: 0 };
|
|
308
|
+
for (const file of files) {
|
|
309
|
+
if (file.binary) totals.binaryFiles++;
|
|
310
|
+
else if (file.additions === null || file.deletions === null) totals.uncountedFiles++;
|
|
311
|
+
else { totals.additions += file.additions; totals.deletions += file.deletions; }
|
|
312
|
+
}
|
|
313
|
+
return totals;
|
|
314
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { VERSION } from './version.js';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { lstat } from 'node:fs/promises';
|
|
4
5
|
import { parseArgs, promisify } from 'node:util';
|
|
5
6
|
import { execFile } from 'node:child_process';
|
|
6
7
|
import { createInterface } from 'node:readline/promises';
|
|
7
8
|
import { Writable } from 'node:stream';
|
|
8
|
-
import { getPaths, loadConfig, saveConfig, saveOpenRouterKey, openRouterKey, validateConfig, repositoryConfigInfo, policyHash } from './config.js';
|
|
9
|
+
import { getPaths, loadConfig, saveConfig, saveOpenRouterKey, openRouterKey, saveOpenAIEndpoint, openAIEndpoint, validateConfig, repositoryConfigInfo, policyHash } from './config.js';
|
|
9
10
|
import { initializeRepository } from './repo-config.js';
|
|
10
11
|
import * as git from './git.js';
|
|
11
12
|
import { install, uninstall, doctor, dispatchHook, installedAgents, detectAgents } from './install.js';
|
|
@@ -26,14 +27,14 @@ import { toggleYolo, yoloStatus, recentOverrides } from './overrides.js';
|
|
|
26
27
|
|
|
27
28
|
const CLI_PATH = fileURLToPath(new URL('../bin/sphinx.js', import.meta.url));
|
|
28
29
|
const POLICY_FLAGS = ['branches', 'events', 'level', 'remotes'];
|
|
29
|
-
const MODEL_FLAGS = ['preset', 'provider', 'model', 'small', 'medium', 'large', 'allow-paid', 'max-model-calls', 'max-output-tokens'];
|
|
30
|
+
const MODEL_FLAGS = ['preset', 'provider', 'model', 'small', 'medium', 'large', 'allow-paid', 'max-model-calls', 'max-output-tokens', 'cli-billing'];
|
|
30
31
|
const OPTIONS = {
|
|
31
32
|
install: ['yes', 'repo', 'agents', ...MODEL_FLAGS, 'launch', 'no-launch', ...POLICY_FLAGS],
|
|
32
33
|
activate: ['agents', 'json'],
|
|
33
34
|
init: ['yes', 'json', 'context', ...POLICY_FLAGS], check: ['json', 'id', 'remote', 'branch', 'staged', 'retry-review'],
|
|
34
35
|
prepare: ['json', 'worker', 'retry'],
|
|
35
36
|
answer: ['json', 'text'], explain: ['json'], dispute: ['json', 'text'], pending: ['json'],
|
|
36
|
-
status: ['json'], config: ['json', ...MODEL_FLAGS], doctor: ['json'], auth: [], uninstall: ['repo'],
|
|
37
|
+
status: ['json'], config: ['json', ...MODEL_FLAGS], doctor: ['json'], auth: ['provider', 'base-url'], uninstall: ['repo'],
|
|
37
38
|
mcp: [], yolo: ['json', 'reason'],
|
|
38
39
|
};
|
|
39
40
|
const json = value => console.log(JSON.stringify(value, null, 2));
|
|
@@ -142,6 +143,7 @@ export function applyInstallOptions(current, values) {
|
|
|
142
143
|
}
|
|
143
144
|
for (const tier of ['small', 'medium', 'large']) if (values[tier] !== undefined) config.models[tier] = parseRoute(values[tier]);
|
|
144
145
|
if (values['allow-paid']) config.allowPaid = true;
|
|
146
|
+
if (values['cli-billing'] !== undefined) config.cliBilling = values['cli-billing'];
|
|
145
147
|
validateConfig(config);
|
|
146
148
|
if (values.agents !== undefined && !['auto', 'none'].includes(values.agents)) {
|
|
147
149
|
const selected = values.agents.split(',');
|
|
@@ -169,6 +171,7 @@ function printConfiguration(terminal, config, { routes = true } = {}) {
|
|
|
169
171
|
if (routes) {
|
|
170
172
|
terminal.section('Evaluator routes');
|
|
171
173
|
terminal.rows(['small', 'medium', 'large'].map(tier => [tier[0].toUpperCase() + tier.slice(1), routeText(config.models[tier])]));
|
|
174
|
+
if (Object.values(config.models).some(route => ['codex', 'claude'].includes(route.provider))) terminal.rows([['Codex/Claude billing', config.cliBilling === 'api' ? 'API keys from your environment, when set' : 'Your signed-in plan; API keys in your environment are ignored']]);
|
|
172
175
|
terminal.blank(); terminal.note(`Model calls: ${modelLimitText(config.maxModelCalls)}. Review, compaction, grading, learning, and failed requests stay counted. Completion lasts ${config.receiptHours} hours unless code, destination, or policy changes.`);
|
|
173
176
|
terminal.note(`Review planning targets: ${reviewCapsText(config)} calls. These are soft targets; only explicitly configured request limits stop further calls. Context limits and deadlines still apply.`);
|
|
174
177
|
terminal.note('A call cap is optional. Set one with sphinx config --max-model-calls 30, or clear it with --max-model-calls unlimited.');
|
|
@@ -205,6 +208,26 @@ async function activateCommand(values) {
|
|
|
205
208
|
return result.results.some(item => item.status !== 'opened') ? 1 : 0;
|
|
206
209
|
}
|
|
207
210
|
|
|
211
|
+
async function readHidden(prompt) {
|
|
212
|
+
const hidden = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
|
|
213
|
+
process.stdout.write(prompt);
|
|
214
|
+
try { return await withReadline(ask => ask(''), { output: hidden, terminal: true }); } finally { process.stdout.write('\n'); }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function authOpenAI(baseUrlFlag) {
|
|
218
|
+
if (!process.stdin.isTTY) throw new Error('Set SPHINX_OPENAI_BASE_URL and SPHINX_OPENAI_API_KEY in noninteractive environments; sphinx auth requires a terminal.');
|
|
219
|
+
const terminal = ui();
|
|
220
|
+
terminal.section('OpenAI-compatible endpoint');
|
|
221
|
+
terminal.paragraph('Any server with an OpenAI chat-completions API: OpenAI, Groq, Together, vLLM, Ollama, LM Studio, and others. It must support tool calls and JSON-schema structured output. The key is only ever sent to this URL.');
|
|
222
|
+
const baseUrl = baseUrlFlag ?? await withReadline(ask => ask('\nBase URL [https://api.openai.com/v1]: '));
|
|
223
|
+
terminal.note('Input is hidden. Leave it empty only for a localhost server without a key.');
|
|
224
|
+
const key = await readHidden('\nAPI key: ');
|
|
225
|
+
const saved = await saveOpenAIEndpoint({ baseUrl: baseUrl.trim() || 'https://api.openai.com/v1', apiKey: key });
|
|
226
|
+
terminal.success(`Endpoint saved: ${saved}`); terminal.note(getPaths().credentials);
|
|
227
|
+
terminal.paragraph('Route reviews to it with a model ID your endpoint serves, for example:');
|
|
228
|
+
terminal.command('sphinx config --provider openai --model gpt-5-mini');
|
|
229
|
+
}
|
|
230
|
+
|
|
208
231
|
async function auth() {
|
|
209
232
|
if (!process.stdin.isTTY) throw new Error('Use OPENROUTER_API_KEY in noninteractive environments; sphinx auth requires a terminal.');
|
|
210
233
|
const terminal = ui();
|
|
@@ -410,6 +433,26 @@ async function statusCommand(command, values) {
|
|
|
410
433
|
terminal.blank(); terminal.note('Status uses local Git state only. Agent hook trust is not verified here.'); return 0;
|
|
411
434
|
}
|
|
412
435
|
|
|
436
|
+
// Parses and validates a command line before any command runs; exported so documented commands can be checked.
|
|
437
|
+
export function parseCommandLine(argv) {
|
|
438
|
+
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
|
|
439
|
+
help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' }, json: { type: 'boolean' }, yes: { type: 'boolean' }, repo: { type: 'boolean' }, staged: { type: 'boolean' },
|
|
440
|
+
preset: { type: 'string' }, provider: { type: 'string' }, model: { type: 'string' }, small: { type: 'string' }, medium: { type: 'string' }, large: { type: 'string' }, level: { type: 'string' }, branches: { type: 'string' }, events: { type: 'string' }, agents: { type: 'string' }, remotes: { type: 'string' },
|
|
441
|
+
remote: { type: 'string' }, branch: { type: 'string' }, id: { type: 'string' }, text: { type: 'string' }, 'allow-paid': { type: 'boolean' },
|
|
442
|
+
context: { type: 'string' }, worker: { type: 'boolean' }, retry: { type: 'boolean' }, reason: { type: 'string' }, 'base-url': { type: 'string' }, 'cli-billing': { type: 'string' },
|
|
443
|
+
launch: { type: 'boolean' }, 'no-launch': { type: 'boolean' }, 'retry-review': { type: 'boolean' }, 'max-model-calls': { type: 'string' }, 'max-output-tokens': { type: 'string' },
|
|
444
|
+
} });
|
|
445
|
+
const [command, id] = positionals;
|
|
446
|
+
if (values.version || values.help || !command || command === 'help') return { values, command, id };
|
|
447
|
+
if (!HELP[command]) throw new Error(`Unknown command ${command}. Run sphinx --help.`);
|
|
448
|
+
for (const key of Object.keys(values)) if (!OPTIONS[command].includes(key)) throw new Error(`--${key} is not an option for ${command}. Run sphinx ${command} --help.`);
|
|
449
|
+
const needsId = ['answer', 'explain', 'dispute'].includes(command);
|
|
450
|
+
if (positionals.length > (needsId ? 2 : 1)) throw new Error(`Unexpected argument. Usage: ${HELP[command].usage}`);
|
|
451
|
+
if (needsId && !id) throw new Error(`An assessment ID is required. Run sphinx pending to find it. Usage: ${HELP[command].usage}`);
|
|
452
|
+
if (['answer', 'dispute'].includes(command) && !values.text?.trim()) throw new Error(`--text is required. Usage: ${HELP[command].usage}`);
|
|
453
|
+
return { values, command, id };
|
|
454
|
+
}
|
|
455
|
+
|
|
413
456
|
export async function main(argv) {
|
|
414
457
|
if (argv[0] === 'mcp' && argv.length === 1) {
|
|
415
458
|
const { runMcpServer } = await import('./mcp.js');
|
|
@@ -427,22 +470,9 @@ export async function main(argv) {
|
|
|
427
470
|
}
|
|
428
471
|
throw new Error('Unknown hook entry point.');
|
|
429
472
|
}
|
|
430
|
-
const { values,
|
|
431
|
-
|
|
432
|
-
preset: { type: 'string' }, provider: { type: 'string' }, model: { type: 'string' }, small: { type: 'string' }, medium: { type: 'string' }, large: { type: 'string' }, level: { type: 'string' }, branches: { type: 'string' }, events: { type: 'string' }, agents: { type: 'string' }, remotes: { type: 'string' },
|
|
433
|
-
remote: { type: 'string' }, branch: { type: 'string' }, id: { type: 'string' }, text: { type: 'string' }, 'allow-paid': { type: 'boolean' },
|
|
434
|
-
context: { type: 'string' }, worker: { type: 'boolean' }, retry: { type: 'boolean' }, reason: { type: 'string' },
|
|
435
|
-
launch: { type: 'boolean' }, 'no-launch': { type: 'boolean' }, 'retry-review': { type: 'boolean' }, 'max-model-calls': { type: 'string' }, 'max-output-tokens': { type: 'string' },
|
|
436
|
-
} });
|
|
437
|
-
const [command, id] = positionals;
|
|
438
|
-
if (values.version) { if (values.json) json({ version: '0.1.0' }); else console.log('0.1.0'); return 0; }
|
|
473
|
+
const { values, command, id } = parseCommandLine(argv);
|
|
474
|
+
if (values.version) { if (values.json) json({ version: VERSION }); else console.log(VERSION); return 0; }
|
|
439
475
|
if (values.help || !command || command === 'help') { printHelp(ui(), command === 'help' ? id : command, { json: values.json }); return 0; }
|
|
440
|
-
if (!HELP[command]) throw new Error(`Unknown command ${command}. Run sphinx --help.`);
|
|
441
|
-
for (const key of Object.keys(values)) if (!OPTIONS[command].includes(key)) throw new Error(`--${key} is not an option for ${command}. Run sphinx ${command} --help.`);
|
|
442
|
-
const needsId = ['answer', 'explain', 'dispute'].includes(command);
|
|
443
|
-
if (positionals.length > (needsId ? 2 : 1)) throw new Error(`Unexpected argument. Usage: ${HELP[command].usage}`);
|
|
444
|
-
if (needsId && !id) throw new Error(`An assessment ID is required. Run sphinx pending to find it. Usage: ${HELP[command].usage}`);
|
|
445
|
-
if (['answer', 'dispute'].includes(command) && !values.text?.trim()) throw new Error(`--text is required. Usage: ${HELP[command].usage}`);
|
|
446
476
|
if (command === 'install') return installCommand(values);
|
|
447
477
|
if (command === 'activate') return activateCommand(values);
|
|
448
478
|
if (command === 'init') return initCommand(values);
|
|
@@ -460,7 +490,12 @@ export async function main(argv) {
|
|
|
460
490
|
}
|
|
461
491
|
return 0;
|
|
462
492
|
}
|
|
463
|
-
if (command === 'auth') {
|
|
493
|
+
if (command === 'auth') {
|
|
494
|
+
if (values.provider === 'openai') { await authOpenAI(values['base-url']); return 0; }
|
|
495
|
+
if (values.provider !== undefined && values.provider !== 'openrouter') throw new Error('sphinx auth saves credentials for --provider openrouter (default) or --provider openai. Codex and Claude use their own sign-in.');
|
|
496
|
+
if (values['base-url'] !== undefined) throw new Error('--base-url applies to --provider openai.');
|
|
497
|
+
await auth(); return 0;
|
|
498
|
+
}
|
|
464
499
|
if (command === 'uninstall') { printInstallationResult(ui(), await uninstall({ repo: !!values.repo })); return 0; }
|
|
465
500
|
if (command === 'doctor') {
|
|
466
501
|
const result = await doctor();
|
|
@@ -488,6 +523,7 @@ export async function main(argv) {
|
|
|
488
523
|
terminal.note('Changing evaluator routes applies to fresh checkpoints. Existing checkpoints remain bound to their original assessment policy.');
|
|
489
524
|
}
|
|
490
525
|
terminal.note(result.path);
|
|
526
|
+
if (Object.values(config.models).some(route => route.provider === 'openai') && !(await openAIEndpoint().catch(() => null))) terminal.warning('No OpenAI-compatible endpoint is saved yet. Run sphinx auth --provider openai before the next check.');
|
|
491
527
|
terminal.paragraph(`Model calls: ${modelLimitText(maxModelCalls)}. Review, compaction, grading, learning follow-ups, and failed requests stay counted. Saving configuration makes no model request.`);
|
|
492
528
|
terminal.note(`Review planning targets: ${reviewCapsText(config)} calls. Explicit request caps, context limits, and deadlines still apply.`);
|
|
493
529
|
return 0;
|
|
@@ -512,11 +548,12 @@ export async function main(argv) {
|
|
|
512
548
|
const where = result.scope === 'repo' ? `for ${result.repoRoot}` : 'globally (every repository without its own setting)';
|
|
513
549
|
const terminal = ui();
|
|
514
550
|
if (result.enabled) { terminal.warning(`Yolo mode is ON ${where}. Git operations skip the understanding check; each skip is logged.`); terminal.command('sphinx yolo # run again to turn it off'); }
|
|
551
|
+
else if (result.reposStillOn) { terminal.success(`Yolo mode is OFF ${where}.`); terminal.warning(`Checks are still skipped in ${result.reposStillOn.length === 1 ? 'a repository' : `${result.reposStillOn.length} repositories`} with ${result.reposStillOn.length === 1 ? 'its' : 'their'} own yolo setting: ${result.reposStillOn.join(', ')}. Run sphinx yolo inside each to turn it off.`); }
|
|
515
552
|
else terminal.success(`Yolo mode is OFF ${where}. Understanding checks are back on.`);
|
|
516
553
|
return 0;
|
|
517
554
|
}
|
|
518
555
|
if (['pending', 'status'].includes(command)) return statusCommand(command, values);
|
|
519
|
-
if (
|
|
556
|
+
if (['answer', 'explain', 'dispute'].includes(command)) {
|
|
520
557
|
const { repo, config } = await repoConfig(), existing = await store.read(id);
|
|
521
558
|
if (existing.snapshot.repoRoot !== repo.root) throw new Error('This checkpoint belongs to a different repository.');
|
|
522
559
|
const learn = command === 'explain', dispute = command === 'dispute';
|
package/src/config.js
CHANGED
|
@@ -4,12 +4,15 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { constants } from 'node:fs';
|
|
5
5
|
import { mkdir, open, writeFile, rename, chmod, lstat, unlink } from 'node:fs/promises';
|
|
6
6
|
|
|
7
|
+
// openai means any OpenAI chat-completions-compatible endpoint the user saves.
|
|
8
|
+
export const PROVIDERS = Object.freeze(['openrouter', 'codex', 'claude', 'openai', 'auto']);
|
|
9
|
+
|
|
7
10
|
export const DEFAULT_CONFIG = {
|
|
8
11
|
version: 1, enabled: true, provider: 'openrouter', model: 'openrouter/free',
|
|
9
12
|
level: 'standard', mode: 'block', events: ['pre-push'],
|
|
10
13
|
branches: ['main', 'master'], remotes: ['*'], exclude: [],
|
|
11
14
|
maxContextBytes: 100000, timeoutSeconds: 180, receiptHours: 24,
|
|
12
|
-
maxModelCalls: null, maxOutputTokens: 4096, allowPaid: false,
|
|
15
|
+
maxModelCalls: null, maxOutputTokens: 4096, allowPaid: false, cliBilling: 'plan',
|
|
13
16
|
agentMaxTurns: null, toolResultBytes: 16000,
|
|
14
17
|
backgroundPreparation: true, preparationDebounceMs: 1500,
|
|
15
18
|
models: {
|
|
@@ -70,7 +73,7 @@ export function validateConfig(config) {
|
|
|
70
73
|
for (const key of Object.keys(config)) if (!allowed.has(key)) fail(`unknown setting ${key}`);
|
|
71
74
|
if (config.version !== 1) fail('version must be 1');
|
|
72
75
|
for (const key of ['enabled', 'allowPaid', 'backgroundPreparation']) if (typeof config[key] !== 'boolean') fail(`${key} must be boolean`);
|
|
73
|
-
for (const [key, values] of Object.entries({ provider:
|
|
76
|
+
for (const [key, values] of Object.entries({ provider: PROVIDERS, level: ['relaxed','standard','thorough'], mode: ['block'], cliBilling: ['plan','api'] })) {
|
|
74
77
|
if (key === 'mode' && config.mode === 'advisory') fail('mode "advisory" was removed. Remove mode from your configuration; use sphinx yolo to bypass checks when urgent');
|
|
75
78
|
if (!values.includes(config[key])) fail(`${key} must be ${values.join(', ')}`);
|
|
76
79
|
}
|
|
@@ -87,7 +90,8 @@ export function validateConfig(config) {
|
|
|
87
90
|
if (!config.models || typeof config.models !== 'object') fail('models must define small, medium, and large routes');
|
|
88
91
|
for (const tier of ['small','medium','large']) {
|
|
89
92
|
const route = config.models[tier];
|
|
90
|
-
if (!route || !
|
|
93
|
+
if (!route || !PROVIDERS.includes(route.provider)) fail(`models.${tier}.provider is invalid`);
|
|
94
|
+
if (route.provider === 'openai' && !route.model) fail(`models.${tier} needs a model ID from your endpoint, for example openai:gpt-5-mini`);
|
|
91
95
|
if (route.model !== null && (typeof route.model !== 'string' || !route.model.trim())) fail(`models.${tier}.model must be a string or null`);
|
|
92
96
|
if (Object.keys(route).some(k => !['provider','model'].includes(k))) fail(`unexpected field in models.${tier}`);
|
|
93
97
|
}
|
|
@@ -178,12 +182,21 @@ export function policyHash(config) {
|
|
|
178
182
|
// checkpoint identity within the same assessment contract.
|
|
179
183
|
// Version the contract when assessment semantics change: older questions and
|
|
180
184
|
// receipts must not stand in for an assessment under the new requirements.
|
|
181
|
-
return createHash('sha256').update(stableStringify({ ...config, maxModelCalls: 10, agentMaxTurns: 8, assessmentContract: 3 })).digest('hex');
|
|
185
|
+
return createHash('sha256').update(stableStringify({ ...config, maxModelCalls: 10, agentMaxTurns: 8, cliBilling: 'plan', assessmentContract: 3 })).digest('hex');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const isRecord = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
189
|
+
// Saving one credential keeps the others.
|
|
190
|
+
async function updateCredentials(env, update) {
|
|
191
|
+
const file = getPaths(env).credentials;
|
|
192
|
+
let current = {};
|
|
193
|
+
try { const value = await readJson(file, {}); if (isRecord(value)) current = value; } catch {}
|
|
194
|
+
await writeJson(file, update({ ...current }));
|
|
182
195
|
}
|
|
183
196
|
|
|
184
197
|
export async function saveOpenRouterKey(key, env = process.env) {
|
|
185
198
|
if (typeof key !== 'string' || !key.trim() || /\s/.test(key.trim())) throw new Error('Enter a valid OpenRouter API key.');
|
|
186
|
-
await
|
|
199
|
+
await updateCredentials(env, credentials => ({ ...credentials, openrouter: key.trim() }));
|
|
187
200
|
}
|
|
188
201
|
export async function openRouterKey(env = process.env) {
|
|
189
202
|
if (env.OPENROUTER_API_KEY) return env.OPENROUTER_API_KEY;
|
|
@@ -194,3 +207,42 @@ export async function openRouterKey(env = process.env) {
|
|
|
194
207
|
return key || null;
|
|
195
208
|
} catch { throw new Error('Could not read Sphinx credentials. Run sphinx auth to save your key again.'); }
|
|
196
209
|
}
|
|
210
|
+
|
|
211
|
+
// OpenAI-compatible endpoints. The URL and key are stored together, so a saved
|
|
212
|
+
// key is only ever sent to the URL it was saved with. OPENAI_API_KEY is never read.
|
|
213
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
214
|
+
export function normalizeBaseUrl(value) {
|
|
215
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
216
|
+
let url;
|
|
217
|
+
try { url = new URL(text); } catch { throw new Error('Enter a base URL such as https://api.openai.com/v1.'); }
|
|
218
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOOPBACK.has(url.hostname))) throw new Error('The base URL must use https://. Plain http:// is allowed only for localhost.');
|
|
219
|
+
if (url.username || url.password || url.search || url.hash) throw new Error('Enter the base URL without credentials, a query string, or a fragment.');
|
|
220
|
+
// Accept a pasted full endpoint; requests always go to <base URL>/chat/completions.
|
|
221
|
+
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/u, '').replace(/\/chat\/completions$/u, '')}`;
|
|
222
|
+
}
|
|
223
|
+
const validKey = key => typeof key === 'string' && /^[\x21-\x7e]{1,4096}$/u.test(key);
|
|
224
|
+
|
|
225
|
+
/** A key is optional only for a localhost server such as Ollama or LM Studio. */
|
|
226
|
+
export async function saveOpenAIEndpoint({ baseUrl, apiKey }, env = process.env) {
|
|
227
|
+
const url = normalizeBaseUrl(baseUrl), key = typeof apiKey === 'string' && apiKey.trim() ? apiKey.trim() : null;
|
|
228
|
+
if (key && !validKey(key)) throw new Error('Enter the API key as a single token without spaces.');
|
|
229
|
+
if (!key && !LOOPBACK.has(new URL(url).hostname)) throw new Error('This endpoint needs an API key. Only localhost endpoints may omit it.');
|
|
230
|
+
await updateCredentials(env, credentials => ({ ...credentials, openai: { baseUrl: url, ...(key ? { apiKey: key } : {}) } }));
|
|
231
|
+
return url;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** SPHINX_OPENAI_BASE_URL and SPHINX_OPENAI_API_KEY replace the saved endpoint as a pair. */
|
|
235
|
+
export async function openAIEndpoint(env = process.env) {
|
|
236
|
+
if (env.SPHINX_OPENAI_BASE_URL || env.SPHINX_OPENAI_API_KEY) {
|
|
237
|
+
if (!env.SPHINX_OPENAI_BASE_URL) throw new Error('Set SPHINX_OPENAI_BASE_URL together with SPHINX_OPENAI_API_KEY.');
|
|
238
|
+
const key = env.SPHINX_OPENAI_API_KEY?.trim() || null;
|
|
239
|
+
if (key && !validKey(key)) throw new Error('SPHINX_OPENAI_API_KEY must be a single token without spaces.');
|
|
240
|
+
return { baseUrl: normalizeBaseUrl(env.SPHINX_OPENAI_BASE_URL), apiKey: key };
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const value = (await readJson(getPaths(env).credentials, {}))?.openai;
|
|
244
|
+
if (value === undefined) return null;
|
|
245
|
+
if (!isRecord(value) || (value.apiKey !== undefined && !validKey(value.apiKey))) throw new Error('invalid');
|
|
246
|
+
return { baseUrl: normalizeBaseUrl(value.baseUrl), apiKey: value.apiKey ?? null };
|
|
247
|
+
} catch { throw new Error('Could not read the saved OpenAI-compatible endpoint. Run sphinx auth --provider openai to save it again.'); }
|
|
248
|
+
}
|