koneck 2.71.1 → 2.72.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/dist/ink-chat.js CHANGED
@@ -7,7 +7,7 @@ import { estimateCost, formatCost } from './pricing.js';
7
7
  import { generateSessionId, saveSession, listSessions, loadSession, relativeAge, renameSession, forkSession, archiveSession, findSession, } from './session.js';
8
8
  import { loadMemory } from './memory.js';
9
9
  import { loadKoneckConfig, saveKoneckConfig, setConfigKey, configKeyDescriptions, CONFIG_FILE, CONFIG_SCHEMA, stepValue, displayValue } from './config-store.js';
10
- import { PROVIDERS, resolveProvider, getApiKey, addUserProvider, userProvidersPath, servesLocally, servesOwnWeights, endpointForProvider } from './providers.js';
10
+ import { PROVIDERS, resolveProvider, getApiKey, addUserProvider, removeUserProvider, userProvidersPath, keyEnvFor, BUILT_INS as BUILT_IN_PROVIDERS, servesLocally, servesOwnWeights, endpointForProvider } from './providers.js';
11
11
  import { normaliseEndpoint, withEndpoint, savedEndpoints, shortSource, resolveModelChoice, withModel } from './endpoints.js';
12
12
  import { loadProjectConfig, loadGlobalConfig, saveGlobalConfig, withoutStoredApiKey } from './config.js';
13
13
  import { rememberKey, forgetAllKeys } from './credentials.js';
@@ -200,6 +200,37 @@ export function wrapPreserving(text, width) {
200
200
  return out;
201
201
  }
202
202
  /** Beyond this a paste is summarised in the transcript; it is still sent in full. */
203
+ /**
204
+ * The picker row that declares a provider rather than choosing one.
205
+ *
206
+ * A sentinel rather than a name, so it can never collide with a real provider — somebody is entitled
207
+ * to declare one called "add".
208
+ */
209
+ export const ADD_PROVIDER = 'add-provider';
210
+ /**
211
+ * Checks one field and says what is wrong with it, or nothing.
212
+ *
213
+ * Separate from the component so the rules can be tested without mounting an Ink app — and they are
214
+ * worth testing: the name rule is what keeps a provider addressable from a command line, and the key
215
+ * variable rule is the one that stops a pasted credential being written into a JSON file.
216
+ */
217
+ export function addProviderProblem(step, value) {
218
+ const said = value.trim();
219
+ if (step === 'name') {
220
+ if (said === '')
221
+ return 'A name is needed — it is how you refer to this provider afterwards.';
222
+ if (!/^[a-z0-9][a-z0-9._-]{0,40}$/.test(said.toLowerCase())) {
223
+ return 'Letters, digits, dot, dash and underscore only, starting with a letter or digit.';
224
+ }
225
+ return null;
226
+ }
227
+ if (step === 'url') {
228
+ if (!/^https?:\/\/.+/i.test(said))
229
+ return 'That is not an http or https URL.';
230
+ return null;
231
+ }
232
+ return null;
233
+ }
203
234
  export const MAX_PASTE_ROWS = 80;
204
235
  /**
205
236
  * The pasted text as it should appear once submitted: what was copied, structure intact.
@@ -1776,6 +1807,16 @@ export function App({ config: initialConfig, clearFrame }) {
1776
1807
  // to the client as a runtime override — KONECK never writes it to disk, and it is never put
1777
1808
  // into a transcript row, so it cannot end up in a saved session file.
1778
1809
  const [keyPrompt, setKeyPrompt] = useState(null);
1810
+ /**
1811
+ * Declaring a provider, one field at a time.
1812
+ *
1813
+ * A step at a time rather than one long command, because the command form already existed and was
1814
+ * the problem: `/connect openrouter https://openrouter.ai/api/v1 anthropic/claude-sonnet-4.5` is
1815
+ * three things to know before you can type anything at all. Asked one at a time, each question
1816
+ * carries its own example and its own refusal, and the endpoint is checked before it is written
1817
+ * rather than failing on the first message afterwards.
1818
+ */
1819
+ const [addProv, setAddProv] = useState(null);
1779
1820
  const [picker, setPicker] = useState(null);
1780
1821
  const [pickIndex, setPickIndex] = useState(0);
1781
1822
  const [pickQuery, setPickQuery] = useState('');
@@ -2429,13 +2470,103 @@ export function App({ config: initialConfig, clearFrame }) {
2429
2470
  current: m.id === cfg.model,
2430
2471
  }));
2431
2472
  }
2473
+ /**
2474
+ * The providers to choose from, with declaring one as the first choice.
2475
+ *
2476
+ * Declaring has been possible from the terminal all along — `/connect <name> <url>` writes it to
2477
+ * disk and makes it a first-class choice — but nothing said so. The picker listed what existed and
2478
+ * stopped there, `/provider` with no argument printed the same list and a usage line about
2479
+ * switching, and the browser meanwhile had a visible "+ add a provider" button. So the answer to
2480
+ * "how do I add one here" was a command form you had to already know. It is an entry in the list
2481
+ * now, where somebody looking for it is looking.
2482
+ */
2432
2483
  function providerItems() {
2433
- return Object.values(PROVIDERS).map(p => ({
2434
- value: p.name,
2435
- label: p.name,
2436
- desc: `${p.displayName} - ${p.defaultModel}` + (p.userDefined ? ' (yours)' : ''),
2437
- current: p.name === cfg.provider,
2438
- }));
2484
+ return [
2485
+ { value: ADD_PROVIDER, label: '+ add a provider',
2486
+ desc: 'an OpenAI-compatible endpoint KONECK does not ship with' },
2487
+ ...Object.values(PROVIDERS).map(p => ({
2488
+ value: p.name,
2489
+ label: p.name,
2490
+ desc: `${p.displayName} - ${p.defaultModel}` + (p.userDefined ? ' (yours)' : ''),
2491
+ current: p.name === cfg.provider,
2492
+ })),
2493
+ ];
2494
+ }
2495
+ /** Opens the declare-a-provider prompt, optionally with fields already known. */
2496
+ function startAddProvider(seed = {}) {
2497
+ setAddProv({ step: 'name', name: '', url: '', model: '', value: '', ...seed });
2498
+ }
2499
+ /**
2500
+ * Takes what was typed into the current field and moves on, or refuses it.
2501
+ *
2502
+ * The built-in check sits between the endpoint and the model rather than at the start, because it
2503
+ * is not about the name being invalid: repointing a name KONECK ships with is a legitimate thing
2504
+ * to want — a company proxy in front of a vendor is exactly this — and the only requirement is
2505
+ * that it cannot happen by accident. Same reasoning as the browser, and the same wording.
2506
+ */
2507
+ async function advanceAddProvider() {
2508
+ const at = addProv;
2509
+ if (!at)
2510
+ return;
2511
+ const said = at.value.trim();
2512
+ const problem = addProviderProblem(at.step, said);
2513
+ if (problem) {
2514
+ setAddProv({ ...at, error: problem });
2515
+ return;
2516
+ }
2517
+ if (at.step === 'name') {
2518
+ const name = said.toLowerCase();
2519
+ setAddProv({ ...at, name, step: 'url', value: '', error: undefined });
2520
+ return;
2521
+ }
2522
+ if (at.step === 'url') {
2523
+ const url = said.replace(/\/+$/, '');
2524
+ setAddProv({ ...at, url, step: 'model', value: '', error: undefined });
2525
+ return;
2526
+ }
2527
+ if (at.step === 'model') {
2528
+ const next = { ...at, model: said, value: '', error: undefined };
2529
+ if (BUILT_IN_PROVIDERS[at.name]) {
2530
+ setAddProv({ ...next, step: 'confirm' });
2531
+ return;
2532
+ }
2533
+ await saveDeclaredProvider(next);
2534
+ return;
2535
+ }
2536
+ // confirm
2537
+ if (/^y(es)?$/i.test(said)) {
2538
+ await saveDeclaredProvider(at);
2539
+ return;
2540
+ }
2541
+ setAddProv(null);
2542
+ addSystem(`Left ${at.name} pointing at ${BUILT_IN_PROVIDERS[at.name]?.baseURL}.`);
2543
+ }
2544
+ /** Writes the declaration and switches to it, which is what somebody adding one meant to do. */
2545
+ async function saveDeclaredProvider(from) {
2546
+ const shadowed = BUILT_IN_PROVIDERS[from.name];
2547
+ let def;
2548
+ try {
2549
+ def = addUserProvider({
2550
+ name: from.name,
2551
+ displayName: from.name,
2552
+ baseURL: from.url,
2553
+ apiKeyEnv: keyEnvFor(from.name),
2554
+ defaultModel: from.model.trim() || 'default',
2555
+ });
2556
+ }
2557
+ catch (e) {
2558
+ setAddProv({ ...from, error: e instanceof Error ? e.message : String(e) });
2559
+ return;
2560
+ }
2561
+ setAddProv(null);
2562
+ addSystem((shadowed
2563
+ ? `⚠️ Repointed **${def.name}** → ${def.baseURL} (was ${shadowed.baseURL})\n`
2564
+ + 'Everything that uses this name now goes there, in the browser as well as here.\n'
2565
+ : `Declared **${def.name}** → ${def.baseURL}\n`)
2566
+ + `Saved to ${userProvidersPath()}. Its key is read from ${def.apiKeyEnv} `
2567
+ + '(or KONECK_API_KEY).\n'
2568
+ + `Remove it again with \`/provider remove ${def.name}\`.`);
2569
+ await choosePick({ value: def.name, label: def.name, desc: '' }, 'provider');
2439
2570
  }
2440
2571
  /**
2441
2572
  * Offers workspace paths for an `@` reference.
@@ -2575,6 +2706,10 @@ export function App({ config: initialConfig, clearFrame }) {
2575
2706
  addSystem(`Model → ${item.value} · remembered for ${cfg.provider} (session reset)`);
2576
2707
  return;
2577
2708
  }
2709
+ if (kind === 'provider' && item.value === ADD_PROVIDER) {
2710
+ startAddProvider();
2711
+ return;
2712
+ }
2578
2713
  if (kind === 'provider') {
2579
2714
  const def = PROVIDERS[item.value];
2580
2715
  // The endpoint saved for the provider being switched to, not its built-in address. Passing
@@ -2975,8 +3110,64 @@ export function App({ config: initialConfig, clearFrame }) {
2975
3110
  return;
2976
3111
  }
2977
3112
  case '/provider': {
3113
+ const [verb, ...restArgs] = arg.trim().split(/\s+/).filter(Boolean);
3114
+ /*
3115
+ * add and remove, named as what they are.
3116
+ *
3117
+ * Declaring was only ever reachable as `/connect <name> <url>`, which is a thing you have to
3118
+ * already know — and somebody looking for how to add a provider looks at `/provider`. The
3119
+ * old form still works; this is where it can be found.
3120
+ */
3121
+ if (verb?.toLowerCase() === 'add') {
3122
+ const [name, url, ...model] = restArgs;
3123
+ // With arguments it is one step; without, it asks. Both end in the same place.
3124
+ startAddProvider({
3125
+ ...(name ? { name: name.toLowerCase(), step: 'url' } : {}),
3126
+ ...(name && url ? { url: url.replace(/\/+$/, ''), step: 'model' } : {}),
3127
+ ...(model.length ? { value: model.join(' ') } : {}),
3128
+ });
3129
+ return;
3130
+ }
3131
+ if (verb?.toLowerCase() === 'remove' || verb?.toLowerCase() === 'forget') {
3132
+ const target = (restArgs[0] ?? '').toLowerCase();
3133
+ if (!target) {
3134
+ const mine = Object.values(PROVIDERS).filter(p => p.userDefined).map(p => p.name);
3135
+ addSystem(mine.length
3136
+ ? `Usage: /provider remove <name>\nYours: ${mine.join(', ')}`
3137
+ : 'You have not declared any providers, so there is nothing to remove.');
3138
+ return;
3139
+ }
3140
+ const wasShadowing = BUILT_IN_PROVIDERS[target] !== undefined;
3141
+ if (!removeUserProvider(target)) {
3142
+ addSystem(PROVIDERS[target]
3143
+ ? `"${target}" ships with KONECK — it was not declared by you, so there is nothing to remove.`
3144
+ : `No declared provider called "${target}".`);
3145
+ return;
3146
+ }
3147
+ addSystem(wasShadowing
3148
+ ? `Removed your "${target}". The built-in one is back, pointing at ${BUILT_IN_PROVIDERS[target].baseURL}.`
3149
+ : `Removed "${target}".`);
3150
+ return;
3151
+ }
2978
3152
  if (!arg) {
2979
- addSystem(`Current provider: ${cfg.provider}\nAvailable: ${Object.keys(PROVIDERS).join(', ')}\nUsage: /provider <name>`);
3153
+ /*
3154
+ * Says where things stand, then opens the picker.
3155
+ *
3156
+ * The list scrolls into the transcript where it can be read back; the picker is for doing
3157
+ * something about it, and carries "+ add a provider" as its first row. An earlier version
3158
+ * of this printed the summary and stopped, and the summary said the picker would open —
3159
+ * which it did not, because nothing opened it. A help line that describes behaviour the
3160
+ * program does not have is worse than no help line.
3161
+ */
3162
+ const mine = Object.values(PROVIDERS).filter(p => p.userDefined).map(p => p.name);
3163
+ addSystem(`Current provider: ${cfg.provider}\n`
3164
+ + `Available: ${Object.keys(PROVIDERS).join(', ')}\n`
3165
+ + (mine.length ? `Declared by you: ${mine.join(', ')}\n` : '')
3166
+ + '\nSwitch: /provider <name>, or pick one below\n'
3167
+ + 'Add: /provider add — asks for a name, an endpoint and a default model\n'
3168
+ + ' /provider add <name> <url> [model] if you have them to hand\n'
3169
+ + 'Remove: /provider remove <name>');
3170
+ openPicker('provider', providerItems());
2980
3171
  return;
2981
3172
  }
2982
3173
  const name = arg.trim().toLowerCase();
@@ -2985,7 +3176,7 @@ export function App({ config: initialConfig, clearFrame }) {
2985
3176
  // Switching to a name that does not resolve used to reset the session anyway, leaving it
2986
3177
  // pointed at nothing and failing on the next message instead of on the command.
2987
3178
  addSystem(`Unknown provider "${arg}". Known: ${Object.keys(PROVIDERS).join(', ')}\n` +
2988
- `Declare one: /connect ${name} https://endpoint/v1`);
3179
+ `Declare it: /provider add ${name} https://endpoint/v1`);
2989
3180
  return;
2990
3181
  }
2991
3182
  // Keep the typed command and picker on one implementation path. The picker clears a
@@ -4084,6 +4275,26 @@ export function App({ config: initialConfig, clearFrame }) {
4084
4275
  answer(typed);
4085
4276
  return;
4086
4277
  }
4278
+ if (addProv) {
4279
+ if (key.escape) {
4280
+ setAddProv(null);
4281
+ addSystem('Cancelled — nothing was declared.');
4282
+ return;
4283
+ }
4284
+ if (key.return) {
4285
+ void advanceAddProvider();
4286
+ return;
4287
+ }
4288
+ if (key.backspace || key.delete) {
4289
+ setAddProv(p => (p ? { ...p, value: p.value.slice(0, -1) } : p));
4290
+ return;
4291
+ }
4292
+ // A pasted endpoint arrives as one chunk, so it is taken whole rather than a letter at a time.
4293
+ if (!key.ctrl && !key.meta && input) {
4294
+ setAddProv(p => (p ? { ...p, value: p.value + input } : p));
4295
+ }
4296
+ return;
4297
+ }
4087
4298
  if (keyPrompt) {
4088
4299
  if (key.escape) {
4089
4300
  setKeyPrompt(null);
@@ -5138,6 +5349,14 @@ export function App({ config: initialConfig, clearFrame }) {
5138
5349
  return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { backgroundColor: selected ? CYAN : undefined, color: selected ? '#10222A' : INK, bold: selected, children: fitCells(`${selected ? G.caret + ' ' : ' '}${label}` +
5139
5350
  `${item.current ? '(current) ' : ''}${header ? `${header} · ` : ''}${item.desc}`, width) }) }, item.value + absolute));
5140
5351
  }), list.length > pickerRows && (_jsxs(Text, { color: DIM, children: ["\u2191\u2193 navigate \u00B7 pgup/pgdn jump \u00B7 enter select \u00B7 showing ", Math.max(0, start) + 1, "\u2013", Math.max(0, start) + shown.length, " of ", list.length] }))] }));
5352
+ })(), addProv && (() => {
5353
+ const shadowed = BUILT_IN_PROVIDERS[addProv.name];
5354
+ const ask = addProv.step === 'name' ? { label: 'name', hint: 'short and lowercase, e.g. openrouter' }
5355
+ : addProv.step === 'url' ? { label: 'endpoint', hint: 'OpenAI-compatible, e.g. https://openrouter.ai/api/v1' }
5356
+ : addProv.step === 'model' ? { label: 'default model', hint: 'optional — enter to skip' }
5357
+ : { label: 'repoint ' + addProv.name + '? (y/n)',
5358
+ hint: `it ships with KONECK, pointing at ${shadowed?.baseURL}` };
5359
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: CYAN, paddingX: 1, marginTop: 1, width: barWidth, children: [_jsx(Text, { color: CYAN, bold: true, children: "Declare a provider" }), _jsxs(Text, { color: MUTED, children: [ask.hint, " \u00B7 esc to cancel"] }), addProv.name !== '' && (_jsxs(Text, { color: DIM, children: [addProv.name, addProv.url ? ' ' + addProv.url : ''] })), addProv.error && _jsx(Text, { color: CRIMSON, children: addProv.error }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: MUTED, children: [ask.label, ": "] }), _jsx(Text, { color: INK, children: addProv.value }), _jsx(Text, { color: CYAN, children: "\u2588" })] })] }));
5141
5360
  })(), keyPrompt && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: AMBER, paddingX: 1, marginTop: 1, width: barWidth, children: [_jsxs(Text, { color: AMBER, bold: true, children: ["API key for ", keyPrompt.provider] }), _jsx(Text, { color: MUTED, children: "Paste it and press enter. Held in memory for this session only; esc to cancel." }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: MUTED, children: [keyPrompt.env, ": "] }), _jsx(Text, { color: INK, children: '*'.repeat(Math.min(keyPrompt.value.length, 48)) }), _jsx(Text, { color: CYAN, children: "\u2588" })] })] })), ask && (() => {
5142
5361
  let detail = '';
5143
5362
  try {