@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,104 @@
|
|
|
1
|
+
import { ProviderError } from '../utils/errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Multi-provider registry.
|
|
5
|
+
*
|
|
6
|
+
* Providers are registered by ID and can be instantiated on demand with a
|
|
7
|
+
* config object. The registry keeps a cache of created providers so the same
|
|
8
|
+
* instance is reused across requests within a session.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const registry = new ProviderRegistry();
|
|
12
|
+
* registry.register('tokenharbor', createTokenHarborProvider);
|
|
13
|
+
* const provider = await registry.resolve('tokenharbor', config, { logger });
|
|
14
|
+
*
|
|
15
|
+
* Provider factories receive (config, deps) and must return an object with at
|
|
16
|
+
* least `chat(...)`, `ping()`, and a `model` getter (matching the existing
|
|
17
|
+
* NvidiaProvider interface).
|
|
18
|
+
*/
|
|
19
|
+
export class ProviderRegistry {
|
|
20
|
+
constructor(factories = {}) {
|
|
21
|
+
this.factories = new Map();
|
|
22
|
+
this.providers = new Map();
|
|
23
|
+
for (const [id, factory] of Object.entries(factories)) {
|
|
24
|
+
this.factories.set(id, factory);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Register a provider factory under an id. Overwrites any existing one. */
|
|
29
|
+
register(id, factory) {
|
|
30
|
+
if (!id || typeof id !== 'string') {
|
|
31
|
+
throw new ProviderError('Provider id must be a non-empty string.', {
|
|
32
|
+
code: 'INVALID_PROVIDER_ID',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (typeof factory !== 'function') {
|
|
36
|
+
throw new ProviderError(`Provider factory for ${id} must be a function.`, {
|
|
37
|
+
code: 'INVALID_PROVIDER_FACTORY',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
this.factories.set(id, factory);
|
|
41
|
+
this.providers.delete(id); // drop any cached instance for the old factory
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Unregister a provider and drop any cached instance. */
|
|
45
|
+
unregister(id) {
|
|
46
|
+
this.factories.delete(id);
|
|
47
|
+
this.providers.delete(id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Check whether a provider id is known (registered or already instantiated). */
|
|
51
|
+
has(id) {
|
|
52
|
+
return this.factories.has(id) || this.providers.has(id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** List all known provider ids. */
|
|
56
|
+
list() {
|
|
57
|
+
return [...this.factories.keys()];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Return a cached provider instance, or null when none exists yet. */
|
|
61
|
+
get(id) {
|
|
62
|
+
return this.providers.get(id) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Create (and cache) a provider instance for the given id. */
|
|
66
|
+
create(id, config, deps = {}) {
|
|
67
|
+
const factory = this.factories.get(id);
|
|
68
|
+
if (!factory) {
|
|
69
|
+
throw new ProviderError(`Unknown provider: ${id}. Registered: ${this.list().join(', ') || '(none)'}`, {
|
|
70
|
+
code: 'UNKNOWN_PROVIDER',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const provider = factory(config, deps);
|
|
74
|
+
this.providers.set(id, provider);
|
|
75
|
+
return provider;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve a provider: return the cached instance or create a new one. */
|
|
79
|
+
resolve(id, config, deps = {}) {
|
|
80
|
+
if (!this.has(id)) {
|
|
81
|
+
throw new ProviderError(`Unknown provider: ${id}. Registered: ${this.list().join(', ') || '(none)'}`, {
|
|
82
|
+
code: 'UNKNOWN_PROVIDER',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const existing = this.providers.get(id);
|
|
86
|
+
if (existing) return existing;
|
|
87
|
+
return this.create(id, config, deps);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Create providers for every registered id (e.g. for health checks). */
|
|
91
|
+
async createAll(config, deps = {}) {
|
|
92
|
+
const instances = new Map();
|
|
93
|
+
for (const id of this.list()) {
|
|
94
|
+
try {
|
|
95
|
+
instances.set(id, this.create(id, config, deps));
|
|
96
|
+
} catch {
|
|
97
|
+
// Providers that fail to instantiate are simply skipped.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return instances;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const defaultRegistry = new ProviderRegistry();
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { MODEL_ID_PATTERN, MAX_MODEL_ID_LENGTH } from './catalog.js';
|
|
2
|
+
import { RIVER_DEFAULT_REASONING_EFFORT, RIVER_PROVIDER_ID, RIVER_REASONING_EFFORTS } from './catalog.js';
|
|
3
|
+
import { registerRequestExtras, passthroughExtras } from './vendor-extras.js';
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
RIVER_BASE_URL,
|
|
7
|
+
RIVER_DEFAULT_MODEL,
|
|
8
|
+
RIVER_DEFAULT_REASONING_EFFORT,
|
|
9
|
+
RIVER_DOCS_URL,
|
|
10
|
+
RIVER_MODEL_CATALOG,
|
|
11
|
+
RIVER_PROVIDER_ID,
|
|
12
|
+
RIVER_REASONING_EFFORTS,
|
|
13
|
+
} from './catalog.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* River AI provider.
|
|
17
|
+
*
|
|
18
|
+
* River speaks the OpenAI Chat Completions protocol, so it reuses the shared
|
|
19
|
+
* client (`nvidia.js`) and only adds the vendor fields that protocol does not
|
|
20
|
+
* define. The client asks `vendor-extras.js` for those fields by provider id,
|
|
21
|
+
* and this module registers the River builder at import time — no subclass, so
|
|
22
|
+
* one client instance keeps working across `/provider` switches.
|
|
23
|
+
*
|
|
24
|
+
* Why this file exists: River keeps thinking *off by default* for its DeepSeek
|
|
25
|
+
* models, so a plain Chat Completions request never produces `reasoning_content`
|
|
26
|
+
* and the CLI has nothing to render. River turns thinking on with a top-level
|
|
27
|
+
* `reasoning_effort` field (see https://docs.river.ai/guides/inference-controls/),
|
|
28
|
+
* which the shared client never sent. `riverRequestExtras()` adds it.
|
|
29
|
+
*
|
|
30
|
+
* The effort is only sent when the model is documented to accept it, and it is
|
|
31
|
+
* only forced for models whose documented default is *off*; every other River
|
|
32
|
+
* model already returns `reasoning_content` by itself.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Per-model reasoning profile, transcribed from
|
|
37
|
+
* https://docs.river.ai/guides/inference-controls/ (read 2026-10-30).
|
|
38
|
+
*
|
|
39
|
+
* `levels` is the model's accepted `reasoning_effort` menu, `onByDefault` tells
|
|
40
|
+
* whether it already thinks without the field, and `enable` is the level used to
|
|
41
|
+
* turn thinking on. A model missing from this table is left untouched: an effort
|
|
42
|
+
* value an endpoint does not know can fail the whole request.
|
|
43
|
+
*/
|
|
44
|
+
export const RIVER_MODEL_REASONING = {
|
|
45
|
+
'deepseek-ai/DeepSeek-V4.1-Flash-1017': {
|
|
46
|
+
levels: ['none', 'low', 'high', 'max'],
|
|
47
|
+
onByDefault: false,
|
|
48
|
+
enable: 'high',
|
|
49
|
+
},
|
|
50
|
+
'deepseek-ai/DeepSeek-V4.1-Flash': {
|
|
51
|
+
levels: ['none', 'low', 'high', 'max'],
|
|
52
|
+
onByDefault: false,
|
|
53
|
+
enable: 'high',
|
|
54
|
+
},
|
|
55
|
+
'zai-org/GLM-5.3-Flash': { levels: ['low', 'high', 'max'], onByDefault: true, enable: 'max' },
|
|
56
|
+
'GLM-5.3': { levels: ['low', 'high', 'max'], onByDefault: true, enable: 'max' },
|
|
57
|
+
'Qwen/Qwen3.8-27B-FP8': { levels: ['none', 'low', 'medium', 'xhigh'], onByDefault: true, enable: 'xhigh' },
|
|
58
|
+
'nvidia/NVIDIA-Nemotron-3.5-Super-VL-120B-A12B-NVFP4': {
|
|
59
|
+
levels: ['none', 'medium', 'high'],
|
|
60
|
+
onByDefault: true,
|
|
61
|
+
enable: 'high',
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Environment override for the River reasoning effort. */
|
|
66
|
+
export const RIVER_EFFORT_ENV = 'RYUU_RIVER_REASONING_EFFORT';
|
|
67
|
+
/** Generic environment override, read after the River-specific one. */
|
|
68
|
+
export const REASONING_EFFORT_ENV = 'RYUU_REASONING_EFFORT';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalize an effort value.
|
|
72
|
+
*
|
|
73
|
+
* Returns one of the documented levels, or null when the value is not a level at
|
|
74
|
+
* all. "off"/"disabled"/false map to "none" so the same word works in the
|
|
75
|
+
* environment variable and in config.json.
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeReasoningEffort(value) {
|
|
78
|
+
if (value === undefined || value === null) return null;
|
|
79
|
+
if (typeof value === 'boolean') return value ? RIVER_DEFAULT_REASONING_EFFORT : 'none';
|
|
80
|
+
const text = String(value).trim().toLowerCase().replace(/[\s_]+/g, '-');
|
|
81
|
+
if (!text) return null;
|
|
82
|
+
if (['off', 'false', 'no', 'disabled', 'disable'].includes(text)) return 'none';
|
|
83
|
+
if (['on', 'true', 'yes', 'enabled'].includes(text)) return RIVER_DEFAULT_REASONING_EFFORT;
|
|
84
|
+
return RIVER_REASONING_EFFORTS.includes(text) ? text : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The effort the user asked for, or null when they asked for nothing.
|
|
89
|
+
*
|
|
90
|
+
* Precedence: `RYUU_RIVER_REASONING_EFFORT`, `reasoningEffort` from config.json,
|
|
91
|
+
* `RYUU_REASONING_EFFORT`, then `capabilities.reasoningEffort` — so an official
|
|
92
|
+
* `river` provider and a hand-written provider entry can both set it.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveRequestedEffort(config = {}, env = process.env) {
|
|
95
|
+
const candidates = [
|
|
96
|
+
env?.[RIVER_EFFORT_ENV],
|
|
97
|
+
config?.reasoningEffort,
|
|
98
|
+
env?.[REASONING_EFFORT_ENV],
|
|
99
|
+
config?.capabilities?.reasoningEffort,
|
|
100
|
+
];
|
|
101
|
+
for (const candidate of candidates) {
|
|
102
|
+
const effort = normalizeReasoningEffort(candidate);
|
|
103
|
+
if (effort) return effort;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The model's documented profile, or null when it is not a known model. */
|
|
109
|
+
export function reasoningProfileFor(model) {
|
|
110
|
+
return RIVER_MODEL_REASONING[String(model || '')] || null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Decide which `reasoning_effort` (if any) to send for a model.
|
|
115
|
+
*
|
|
116
|
+
* Returns null when the field must be omitted — the user asked for nothing and
|
|
117
|
+
* the model already reasons by default, or the requested level is not in the
|
|
118
|
+
* model's documented menu.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveReasoningEffort(config = {}, { env = process.env, model = null, logger = null } = {}) {
|
|
121
|
+
const target = String(model || config?.model || '');
|
|
122
|
+
const profile = reasoningProfileFor(target);
|
|
123
|
+
const requested = resolveRequestedEffort(config, env);
|
|
124
|
+
let effort = null;
|
|
125
|
+
if (requested) {
|
|
126
|
+
effort = requested;
|
|
127
|
+
} else if (config?.thinking === false) {
|
|
128
|
+
effort = 'none';
|
|
129
|
+
} else if (config?.thinking === true) {
|
|
130
|
+
effort = profile?.enable || RIVER_DEFAULT_REASONING_EFFORT;
|
|
131
|
+
} else if (profile && !profile.onByDefault) {
|
|
132
|
+
effort = profile.enable; // DeepSeek: thinking is off unless asked for
|
|
133
|
+
}
|
|
134
|
+
if (!effort || effort === 'none') {
|
|
135
|
+
// "none" only means something to a model that accepts it; elsewhere an
|
|
136
|
+
// absent field falls back to the same model default.
|
|
137
|
+
return effort === 'none' && profile?.levels?.includes('none') ? 'none' : null;
|
|
138
|
+
}
|
|
139
|
+
if (profile && !profile.levels.includes(effort)) {
|
|
140
|
+
logger?.warn('reasoning_effort is not supported by this model; using the provider default', {
|
|
141
|
+
model: target,
|
|
142
|
+
requested: effort,
|
|
143
|
+
supported: profile.levels,
|
|
144
|
+
});
|
|
145
|
+
return profile.enable && profile.levels.includes(profile.enable) ? profile.enable : null;
|
|
146
|
+
}
|
|
147
|
+
return effort;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* River's request-body extension for the shared client.
|
|
152
|
+
*
|
|
153
|
+
* Opaque pass-through fields first (a hand-written `extraProviders` entry may
|
|
154
|
+
* carry `extraBody`/`requestParams`), then the `reasoning_effort` that turns
|
|
155
|
+
* DeepSeek thinking on.
|
|
156
|
+
*/
|
|
157
|
+
export function riverRequestExtras(config = {}, { logger = null, env = process.env } = {}) {
|
|
158
|
+
const extras = passthroughExtras(config);
|
|
159
|
+
const effort = resolveReasoningEffort(config, { model: config.model, logger, env });
|
|
160
|
+
if (effort) extras.reasoning_effort = effort;
|
|
161
|
+
return extras;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** True when a requestExtras() payload carries an explicit reasoning effort. */
|
|
165
|
+
export function hasReasoningEffort(extras) {
|
|
166
|
+
const value = extras?.reasoning_effort;
|
|
167
|
+
if (value === undefined || value === null) return false;
|
|
168
|
+
return !(typeof value === 'string' && !value.trim());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Human-readable effort for /status. */
|
|
172
|
+
export function describeEffort(effort) {
|
|
173
|
+
return !effort || effort === 'none' ? 'off (model default)' : effort;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One line describing the reasoning effort that will be sent, or null when the
|
|
178
|
+
* provider does not use one (so `/status` stays quiet for them).
|
|
179
|
+
*/
|
|
180
|
+
export function describeReasoning(config = {}, { env = process.env, logger = null } = {}) {
|
|
181
|
+
const extras = riverRequestExtras(config, { env, logger });
|
|
182
|
+
if (!hasReasoningEffort(extras)) return null;
|
|
183
|
+
const effort = extras.reasoning_effort;
|
|
184
|
+
return effort === 'none'
|
|
185
|
+
? 'off (reasoning_effort: none)'
|
|
186
|
+
: `reasoning_effort: ${effort}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Sanity check used by tests and `/doctor`: the model id must remain valid. */
|
|
190
|
+
export function isValidRiverModel(model) {
|
|
191
|
+
return typeof model === 'string' && model.length <= MAX_MODEL_ID_LENGTH && MODEL_ID_PATTERN.test(model);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The shared client consults this table on every request. Registering at import
|
|
195
|
+
// time keeps the wiring in one place: `providers/index.js` only has to import
|
|
196
|
+
// this module, and any provider with the id "river" gets the extension. The host
|
|
197
|
+
// fallback also covers a hand-written `extraProviders` entry for the same API.
|
|
198
|
+
registerRequestExtras(RIVER_PROVIDER_ID, riverRequestExtras, { hosts: ['api.river.ai'] });
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { TOKENHARBOR_BASE_URL, TOKENHARBOR_MODEL } from './catalog.js';
|
|
2
|
+
import { registerRequestExtras, passthroughExtras } from './vendor-extras.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Token Harbor provider.
|
|
6
|
+
*
|
|
7
|
+
* It speaks the OpenAI Chat Completions protocol and already returns
|
|
8
|
+
* `reasoning_content`, so it needs no vendor request field: the module exists
|
|
9
|
+
* to own the provider's defaults and to be the place a future Token Harbor-only
|
|
10
|
+
* field would go.
|
|
11
|
+
*
|
|
12
|
+
* A user `extraProviders` entry may still carry `extraBody`/`requestParams`; they
|
|
13
|
+
* are passed through untouched, which makes an unusual gateway reachable without
|
|
14
|
+
* a code change.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const TOKENHARBOR_DOCS_URL = 'https://tokenharbor.ai/';
|
|
18
|
+
|
|
19
|
+
/** Request-body extension for Token Harbor: only explicit pass-through fields. */
|
|
20
|
+
export function tokenHarborRequestExtras(config = {}) {
|
|
21
|
+
return passthroughExtras(config);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Declared descriptor, kept next to the client like the other provider modules. */
|
|
25
|
+
export function tokenHarborDescriptor() {
|
|
26
|
+
return {
|
|
27
|
+
id: 'tokenharbor',
|
|
28
|
+
name: 'Token Harbor',
|
|
29
|
+
apiFormat: 'openai-chat-completions',
|
|
30
|
+
baseURL: TOKENHARBOR_BASE_URL,
|
|
31
|
+
defaultModel: TOKENHARBOR_MODEL,
|
|
32
|
+
models: [{ id: TOKENHARBOR_MODEL, name: 'DeepSeek V4.1 Flash Free', default: true }],
|
|
33
|
+
capabilities: { streaming: true, tools: true, reasoning: true, multimodal: false },
|
|
34
|
+
docsUrl: TOKENHARBOR_DOCS_URL,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
registerRequestExtras('tokenharbor', tokenHarborRequestExtras);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider ids whose request body needs extra fields. Keyed by provider id (and
|
|
3
|
+
* by API host for a hand-written entry pointing at the same endpoint).
|
|
4
|
+
*
|
|
5
|
+
* `passthrough` is the fallback for any provider without a registered builder:
|
|
6
|
+
* a `extraBody`/`requestParams` object in its config.json entry is copied into
|
|
7
|
+
* the request verbatim, so an unusual gateway stays reachable without a code
|
|
8
|
+
* change.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** providerId -> { builder, hosts: Set<string> } */
|
|
12
|
+
const builders = new Map();
|
|
13
|
+
|
|
14
|
+
function hostOf(baseURL) {
|
|
15
|
+
try {
|
|
16
|
+
return new URL(String(baseURL || '')).hostname.toLowerCase();
|
|
17
|
+
} catch {
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Register (or replace) the builder for one provider id.
|
|
24
|
+
*
|
|
25
|
+
* `hosts` makes the builder apply to any provider whose base URL uses that host,
|
|
26
|
+
* which keeps a hand-written `extraProviders` entry for the same vendor working.
|
|
27
|
+
*/
|
|
28
|
+
export function registerRequestExtras(providerId, builder, { hosts = [] } = {}) {
|
|
29
|
+
if (!providerId || typeof builder !== 'function') return;
|
|
30
|
+
const list = [].concat(hosts).map((host) => String(host || '').toLowerCase()).filter(Boolean);
|
|
31
|
+
builders.set(String(providerId), { builder, hosts: new Set(list) });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Remove a builder. Mostly useful for tests. */
|
|
35
|
+
export function unregisterRequestExtras(providerId) {
|
|
36
|
+
builders.delete(String(providerId));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The registered builder for a provider id, then fall back to its API host. */
|
|
40
|
+
export function requestExtrasFor(providerId, baseURL = '') {
|
|
41
|
+
const direct = builders.get(String(providerId || ''));
|
|
42
|
+
if (direct) return direct.builder;
|
|
43
|
+
const host = hostOf(baseURL);
|
|
44
|
+
if (!host) return null;
|
|
45
|
+
for (const entry of builders.values()) {
|
|
46
|
+
if (entry.hosts.has(host)) return entry.builder;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Default builder for providers without one: copy the explicit pass-through
|
|
53
|
+
* objects a config.json entry may carry. Nothing is guessed, so a plain provider
|
|
54
|
+
* still sends only the standard Chat Completions fields.
|
|
55
|
+
*/
|
|
56
|
+
export function passthroughExtras(config = {}) {
|
|
57
|
+
const extras = {};
|
|
58
|
+
for (const field of ['extraBody', 'requestParams']) {
|
|
59
|
+
const value = config?.[field];
|
|
60
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) Object.assign(extras, value);
|
|
61
|
+
}
|
|
62
|
+
return extras;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extra top-level request fields for this provider, or null when it has none.
|
|
67
|
+
* A builder that throws must never break a request: the failure is swallowed
|
|
68
|
+
* and the request goes out unchanged.
|
|
69
|
+
*/
|
|
70
|
+
export function applyRequestExtras(config, context = {}) {
|
|
71
|
+
const builder = requestExtrasFor(config?.providerId, config?.baseURL) || passthroughExtras;
|
|
72
|
+
try {
|
|
73
|
+
const extras = builder(config, context);
|
|
74
|
+
return extras && typeof extras === 'object' && Object.keys(extras).length > 0 ? extras : null;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
context.logger?.warn('provider request extras failed; sending the request unchanged', {
|
|
77
|
+
provider: config?.providerId,
|
|
78
|
+
message: error?.message || String(error),
|
|
79
|
+
});
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copying to the terminal clipboard with OSC 52.
|
|
3
|
+
*
|
|
4
|
+
* The sequence is written to the tty, so the copy happens in the terminal
|
|
5
|
+
* emulator (or terminal multiplexer) rather than through an OS command: it works
|
|
6
|
+
* over SSH, needs no `pbcopy`/`xclip`/`clip.exe`, and touches no other process.
|
|
7
|
+
* Terminals that ignore OSC 52 simply do nothing, which is why callers report the
|
|
8
|
+
* result instead of assuming the clipboard changed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Terminals cap OSC 52 payloads (xterm's default is ~75k of base64). */
|
|
12
|
+
const MAX_CHARS = 100_000;
|
|
13
|
+
|
|
14
|
+
/** The raw sequence for `text`, or null when there is nothing to copy. */
|
|
15
|
+
export function clipboardPayload(text, { limit = MAX_CHARS } = {}) {
|
|
16
|
+
const value = String(text ?? '');
|
|
17
|
+
if (!value.trim()) return null;
|
|
18
|
+
const kept = value.length > limit ? value.slice(0, limit) : value;
|
|
19
|
+
return {
|
|
20
|
+
sequence: `\x1b]52;c;${Buffer.from(kept, 'utf8').toString('base64')}\x1b\\`,
|
|
21
|
+
chars: [...kept].length,
|
|
22
|
+
truncated: kept.length < value.length,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Put `text` on the clipboard. Returns `{ ok, chars, truncated, reason }`; a
|
|
28
|
+
* failure is reported, never thrown, because a copy must not break a session.
|
|
29
|
+
*/
|
|
30
|
+
export function copyToClipboard(text, { output = process.stdout } = {}) {
|
|
31
|
+
const payload = clipboardPayload(text);
|
|
32
|
+
if (!payload) return { ok: false, reason: 'empty', chars: 0 };
|
|
33
|
+
if (!output || output.isTTY === false || !output.write) {
|
|
34
|
+
return { ok: false, reason: 'no-tty', chars: 0 };
|
|
35
|
+
}
|
|
36
|
+
output.write(payload.sequence);
|
|
37
|
+
return { ok: true, chars: payload.chars, truncated: payload.truncated };
|
|
38
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified diff generation for edit previews and /diff.
|
|
3
|
+
* Line-based LCS with a bounded matrix; very large files fall back to a
|
|
4
|
+
* whole-file replacement hunk instead of consuming unbounded memory.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const MAX_CELLS = 2_000_000;
|
|
8
|
+
|
|
9
|
+
function splitLines(text) {
|
|
10
|
+
return String(text ?? '').split('\n');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** @returns {{type: 'equal'|'del'|'add', line: string}[]} */
|
|
14
|
+
export function diffLines(oldText, newText) {
|
|
15
|
+
const a = splitLines(oldText);
|
|
16
|
+
const b = splitLines(newText);
|
|
17
|
+
const n = a.length;
|
|
18
|
+
const m = b.length;
|
|
19
|
+
|
|
20
|
+
if (n * m > MAX_CELLS) {
|
|
21
|
+
return [
|
|
22
|
+
...a.map((line) => ({ type: 'del', line })),
|
|
23
|
+
...b.map((line) => ({ type: 'add', line })),
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const width = m + 1;
|
|
28
|
+
const dp = new Int32Array((n + 1) * width);
|
|
29
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
30
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
31
|
+
dp[i * width + j] = a[i] === b[j]
|
|
32
|
+
? dp[(i + 1) * width + (j + 1)] + 1
|
|
33
|
+
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ops = [];
|
|
38
|
+
let i = 0;
|
|
39
|
+
let j = 0;
|
|
40
|
+
while (i < n && j < m) {
|
|
41
|
+
if (a[i] === b[j]) {
|
|
42
|
+
ops.push({ type: 'equal', line: a[i] });
|
|
43
|
+
i += 1;
|
|
44
|
+
j += 1;
|
|
45
|
+
} else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) {
|
|
46
|
+
ops.push({ type: 'del', line: a[i] });
|
|
47
|
+
i += 1;
|
|
48
|
+
} else {
|
|
49
|
+
ops.push({ type: 'add', line: b[j] });
|
|
50
|
+
j += 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
while (i < n) {
|
|
54
|
+
ops.push({ type: 'del', line: a[i] });
|
|
55
|
+
i += 1;
|
|
56
|
+
}
|
|
57
|
+
while (j < m) {
|
|
58
|
+
ops.push({ type: 'add', line: b[j] });
|
|
59
|
+
j += 1;
|
|
60
|
+
}
|
|
61
|
+
return ops;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Produce a standard unified diff. Returns '' when contents are identical.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} oldText
|
|
68
|
+
* @param {string} newText
|
|
69
|
+
* @param {{oldLabel?: string, newLabel?: string, context?: number, maxLines?: number}} options
|
|
70
|
+
*/
|
|
71
|
+
export function createUnifiedDiff(oldText, newText, {
|
|
72
|
+
oldLabel = 'a/file',
|
|
73
|
+
newLabel = 'b/file',
|
|
74
|
+
context = 3,
|
|
75
|
+
maxLines = 400,
|
|
76
|
+
} = {}) {
|
|
77
|
+
if (oldText === newText) return '';
|
|
78
|
+
const ops = diffLines(oldText, newText);
|
|
79
|
+
|
|
80
|
+
// Mark which ops are changes, then group with context.
|
|
81
|
+
const changed = ops.map((op) => op.type !== 'equal');
|
|
82
|
+
const hunks = [];
|
|
83
|
+
let index = 0;
|
|
84
|
+
while (index < ops.length) {
|
|
85
|
+
if (!changed[index]) {
|
|
86
|
+
index += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let start = Math.max(0, index - context);
|
|
90
|
+
let end = index;
|
|
91
|
+
// Extend while later changes are within 2*context of the current end.
|
|
92
|
+
while (end < ops.length) {
|
|
93
|
+
if (changed[end]) {
|
|
94
|
+
end += 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
let nextChange = end;
|
|
98
|
+
while (nextChange < ops.length && !changed[nextChange]) nextChange += 1;
|
|
99
|
+
if (nextChange < ops.length && nextChange - end <= context * 2) {
|
|
100
|
+
end = nextChange;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
const hunkEnd = Math.min(ops.length, end + context);
|
|
106
|
+
hunks.push({ start, end: hunkEnd });
|
|
107
|
+
index = hunkEnd;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const oldLineAt = new Array(ops.length);
|
|
111
|
+
const newLineAt = new Array(ops.length);
|
|
112
|
+
let oldNo = 1;
|
|
113
|
+
let newNo = 1;
|
|
114
|
+
for (let k = 0; k < ops.length; k += 1) {
|
|
115
|
+
oldLineAt[k] = oldNo;
|
|
116
|
+
newLineAt[k] = newNo;
|
|
117
|
+
if (ops[k].type !== 'add') oldNo += 1;
|
|
118
|
+
if (ops[k].type !== 'del') newNo += 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const output = [`--- ${oldLabel}`, `+++ ${newLabel}`];
|
|
122
|
+
let emitted = 0;
|
|
123
|
+
for (const hunk of hunks) {
|
|
124
|
+
if (emitted >= maxLines) {
|
|
125
|
+
output.push(`@@ diff truncated after ${maxLines} lines @@`);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
const slice = ops.slice(hunk.start, hunk.end);
|
|
129
|
+
const oldCount = slice.filter((op) => op.type !== 'add').length;
|
|
130
|
+
const newCount = slice.filter((op) => op.type !== 'del').length;
|
|
131
|
+
const oldStart = oldCount === 0 ? oldLineAt[hunk.start] - 1 : oldLineAt[hunk.start];
|
|
132
|
+
const newStart = newCount === 0 ? newLineAt[hunk.start] - 1 : newLineAt[hunk.start];
|
|
133
|
+
output.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
|
|
134
|
+
for (const op of slice) {
|
|
135
|
+
const prefix = op.type === 'equal' ? ' ' : op.type === 'del' ? '-' : '+';
|
|
136
|
+
output.push(`${prefix}${op.line}`);
|
|
137
|
+
emitted += 1;
|
|
138
|
+
if (emitted >= maxLines) break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return output.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function diffStats(oldText, newText) {
|
|
145
|
+
const ops = diffLines(oldText, newText);
|
|
146
|
+
let added = 0;
|
|
147
|
+
let removed = 0;
|
|
148
|
+
for (const op of ops) {
|
|
149
|
+
if (op.type === 'add') added += 1;
|
|
150
|
+
else if (op.type === 'del') removed += 1;
|
|
151
|
+
}
|
|
152
|
+
return { added, removed, changed: added + removed };
|
|
153
|
+
}
|