@hncode/hncode 0.4.2 → 0.5.1

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.
Files changed (65) hide show
  1. package/README.md +1 -15
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +149 -35
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +478 -242
  13. package/src/config.js +409 -40
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +518 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/memory-profile.js +247 -0
  27. package/src/notebook.js +232 -0
  28. package/src/output-styles.js +183 -0
  29. package/src/patch.js +248 -0
  30. package/src/permissions.js +130 -1
  31. package/src/plan.js +3 -2
  32. package/src/plugin.js +611 -58
  33. package/src/prompt-presets.js +1 -1
  34. package/src/registry-browser.js +31 -13
  35. package/src/remote.js +20 -2
  36. package/src/rollout.js +151 -0
  37. package/src/row-cache.js +148 -0
  38. package/src/schedule.js +294 -0
  39. package/src/session-approvals.js +86 -0
  40. package/src/session-hub.js +1 -0
  41. package/src/session.js +178 -8
  42. package/src/settings-schema.js +301 -0
  43. package/src/shell-safety.js +473 -0
  44. package/src/side-thread.js +240 -0
  45. package/src/skills.js +27 -0
  46. package/src/stream-buffer.js +350 -0
  47. package/src/subagent-control.js +160 -0
  48. package/src/teleport.js +260 -0
  49. package/src/term-caps.js +411 -0
  50. package/src/term.js +283 -8
  51. package/src/tool-result.js +264 -236
  52. package/src/tools/agent-control.js +98 -0
  53. package/src/tools/agent-swarm.js +11 -2
  54. package/src/tools/agent.js +35 -1
  55. package/src/tools/diagnostics.js +175 -0
  56. package/src/tools/edit.js +25 -10
  57. package/src/tools/grep.js +62 -20
  58. package/src/tools/ignore.js +38 -14
  59. package/src/tools/index.js +10 -0
  60. package/src/tools/notebook.js +113 -0
  61. package/src/tools/read-media-file.js +10 -3
  62. package/src/tools/shell.js +34 -6
  63. package/src/tui.js +5637 -746
  64. package/src/web-public/app.js +7 -0
  65. package/src/web.js +3 -1
package/src/config.js CHANGED
@@ -27,7 +27,10 @@ export const DEFAULTS = {
27
27
  protocol: 'openai',
28
28
  // Fallback context window when neither config nor /v1/models provides one.
29
29
  maxContextTokens: 512000,
30
- maxOutputTokens: 4096,
30
+ // Fallback output-token limit when neither config nor the model itself provides
31
+ // one. This is the per-response cap, not the context window — most models allow
32
+ // well above the old 4096, but it must stay within what the API accepts.
33
+ maxOutputTokens: 10000,
31
34
  reasoning: false,
32
35
  workspace: process.cwd(),
33
36
  allowExternal: false,
@@ -336,7 +339,9 @@ const b = baseUrl.replace(/\/+$/, '');
336
339
  // An empty base URL produces a relative endpoint like "/chat/completions",
337
340
  // which fetch() rejects with a URL-scheme error the model can't decode. Leave
338
341
  // the endpoint empty instead so the caller can show "not set".
339
- const endpoint = !b ? '' : (protocol === 'anthropic' ? `${b}/messages` : `${b}/chat/completions`);
342
+ const endpoint = !b ? '' : (protocol === 'anthropic' ? `${b}/messages`
343
+ : protocol === 'responses' ? `${b}/responses`
344
+ : `${b}/chat/completions`);
340
345
 
341
346
  // Context window: an explicit env/root override wins, then the model's own
342
347
  // `contextLength` (discovered from the provider's /v1/models), then the
@@ -344,7 +349,20 @@ const b = baseUrl.replace(/\/+$/, '');
344
349
  const overrideContext = process.env.HNCODE_MAX_CONTEXT || root.max_context_size || root.max_context_tokens;
345
350
  const modelContext = Number(modelEntry.contextLength || modelEntry.context_length || modelEntry.context_window || modelEntry.max_context_size || 0);
346
351
  const maxContext = Number(overrideContext || modelContext || DEFAULTS.maxContextTokens);
347
- const maxOutput = Number(process.env.HNCODE_MAX_OUTPUT || root.max_output_size || DEFAULTS.maxOutputTokens);
352
+ // Output limit, resolved like the context window: an explicit override wins, then
353
+ // the model's OWN declared limit (from /v1/models), then the default. Ignoring the
354
+ // model's value pinned every model to the default even when it supported far more.
355
+ const modelMaxOutput = Number(
356
+ modelEntry.maxOutputTokens
357
+ || modelEntry.max_output_tokens // models.dev `limit.output`, persisted on the entry
358
+ || modelEntry.max_tokens
359
+ || 0,
360
+ );
361
+ const maxOutput = Number(
362
+ process.env.HNCODE_MAX_OUTPUT
363
+ || root.max_output_size
364
+ || (modelMaxOutput > 0 ? modelMaxOutput : DEFAULTS.maxOutputTokens),
365
+ );
348
366
  const reasoning = process.env.HNCODE_REASONING ? /^(1|true|yes)$/i.test(process.env.HNCODE_REASONING) : !!root.reasoning;
349
367
  const allowExternal = process.env.HNCODE_ALLOW_EXTERNAL === '1' || !!root.tool_allow_external_paths;
350
368
 
@@ -370,6 +388,14 @@ const b = baseUrl.replace(/\/+$/, '');
370
388
  // "use the built-in SYSTEM_PROMPT". Read from config.toml so it persists
371
389
  // across restarts.
372
390
  systemPrompt: process.env.HNCODE_SYSTEM_PROMPT || root.system_prompt || '',
391
+ // The prompt knobs added alongside it, all editable from /settings › Prompt. Separate
392
+ // keys rather than one blob, because they mean different things: `append` keeps the
393
+ // built-in prompt and adds to it, `personal` is the user's own standing note, and the
394
+ // per-mode ones only apply while that mode is on.
395
+ appendSystemPrompt: process.env.HNCODE_APPEND_SYSTEM_PROMPT || root.append_system_prompt || '',
396
+ planInstructions: root.plan_instructions || '',
397
+ focusInstructions: root.focus_instructions || '',
398
+ swarmInstructions: root.swarm_instructions || '',
373
399
  // ---- Web UI daemon -------------------------------------------------------
374
400
  // One daemon serves EVERY hncode session on this machine: it holds the fixed
375
401
  // port, proxies each session's requests to that session's own loopback
@@ -387,13 +413,31 @@ const b = baseUrl.replace(/\/+$/, '');
387
413
  // Backwards-compat: read legacy cool_mode key if calm_mode is not set.
388
414
  : (root.calm_mode === true || root.calm_mode === 'true'
389
415
  || root.cool_mode === true || root.cool_mode === 'true'),
390
- // SUBAGENT MODEL (/swarm-sub-agent): the model each subagent runs on. Empty
391
- // (the default) means "follow this session's model", which is what the Agent
392
- // and AgentSwarm tools fall back to. Set from config.toml so it persists.
393
416
  subagentModel: process.env.HNCODE_SUBAGENT_MODEL || root.subagent_model || '',
394
- // Plugin directory (default: ~/.hncode/plugins). Set to "" or "false" to
395
- // disable plugin loading entirely.
396
- pluginDir: process.env.HNCODE_PLUGINS || root.plugins_dir || '',
417
+ // SECONDARY MODEL (/secondary-model): the cheap model for side work — /btw,
418
+ // /recap and the compaction summary run on it, so a long main-context model is
419
+ // not billed for a question that needed none of that context. Empty means
420
+ // "use the session's model", which is the safe default: a model choice the user
421
+ // never made should not be invented from a model list.
422
+ secondaryModel: process.env.HNCODE_SECONDARY_MODEL || root.secondary_model || '',
423
+ // OUTPUT STYLE (/output-style): the NAME of a style file under
424
+ // ~/.hncode/output-styles/. Stored as a name, not as the text, so editing the
425
+ // file changes the style without touching config.toml.
426
+ outputStyle: process.env.HNCODE_OUTPUT_STYLE || root.output_style || '',
427
+ // FEATURE FLAGS (/experiments): a table of name -> boolean. Read by
428
+ // experiments.js, which owns the registry and the defaults.
429
+ experiments: (root.experiments && typeof root.experiments === 'object') ? root.experiments : {},
430
+ // SCHEDULED PROMPTS (/schedule): cron-like entries that queue a prompt on a
431
+ // running session. Stored as TOML inline tables under [schedule.<id>].
432
+ schedule: (root.schedule && typeof root.schedule === 'object') ? root.schedule : {},
433
+ // Plugin directory (default: ~/.hncode/plugins). Loaded on every launch so
434
+ // plugins installed via /plugins actually take effect. Set to "" or "false"
435
+ // (config or env) to disable plugin loading entirely; unset means the default.
436
+ pluginDir: (() => {
437
+ const v = process.env.HNCODE_PLUGINS || root.plugins_dir;
438
+ if (v === 'false' || v === false || v === '' || v === null || v === undefined) return v === 'false' || v === false || v === '' ? '' : path.join(homeDir(), '.hncode', 'plugins');
439
+ return v;
440
+ })(),
397
441
  // AUTO-UPDATE (/auto-update): when true, hncode checks npm for a newer
398
442
  // version at startup and then every 30 minutes, installing in the background
399
443
  // without interrupting the session.
@@ -444,10 +488,25 @@ const b = baseUrl.replace(/\/+$/, '');
444
488
  promptCache: process.env.HNCODE_PROMPT_CACHE
445
489
  ? /^(1|true|yes|on)$/i.test(process.env.HNCODE_PROMPT_CACHE)
446
490
  : (root.prompt_cache === false || root.prompt_cache === 'false' ? false : true),
491
+ // REDUCED MOTION (/reduced-motion, config.toml or the environment). When on,
492
+ // every animation is replaced by a static or slow-breathing form: the spinner
493
+ // does not rotate, the sweeps do not run, and the pulse does not cycle. For a
494
+ // user who finds the motion distracting or nauseating, and for a terminal that
495
+ // repaints badly, this is the difference between usable and not. Default OFF —
496
+ // motion is the intended experience, so this is an opt-out.
497
+ reducedMotion: process.env.HNCODE_REDUCED_MOTION
498
+ ? /^(1|true|yes|on)$/i.test(process.env.HNCODE_REDUCED_MOTION)
499
+ : (root.reduced_motion === true || root.reduced_motion === 'true'),
500
+ // SHIMMER EDGE (`shimmer_edge`): `cosine` gives the sweep a soft LEADING edge
501
+ // like codex's, `linear` keeps the original hard-on/soft-off ramp. Exposed
502
+ // because the two are a matter of taste and the cost is one branch.
503
+ shimmerEdge: String(root.shimmer_edge || process.env.HNCODE_SHIMMER_EDGE || 'cosine').toLowerCase() === 'linear'
504
+ ? 'linear' : 'cosine',
447
505
  raw: root,
448
506
  };
449
507
  }
450
508
 
509
+
451
510
  export function saveConfig(overlay) {
452
511
  const dir = hncodeConfigFile();
453
512
  const entries = Object.entries(overlay || {});
@@ -617,7 +676,7 @@ function renderTomlString(key, value) {
617
676
 
618
677
  // Append (or replace) a [providers.<name>] table in config.toml without
619
678
  // clobbering unrelated content. Used by /provider's "Add provider…" flow.
620
- export function addProvider(name, { base_url, api_key, protocol } = {}) {
679
+ export function addProvider(name, { base_url, api_key, protocol, known, catalog } = {}) {
621
680
  const file = hncodeConfigFile();
622
681
  let text = '';
623
682
  try { text = fs.readFileSync(file, 'utf8'); } catch { /* new file */ }
@@ -646,6 +705,14 @@ export function addProvider(name, { base_url, api_key, protocol } = {}) {
646
705
  if (base_url) out.push(`base_url = ${q(base_url)}`);
647
706
  if (api_key) out.push(`api_key = ${q(api_key)}`);
648
707
  if (protocol) out.push(`protocol = ${q(protocol)}`);
708
+ // `known` records the SOURCE: true = added from the models.dev catalog, false =
709
+ // a plain endpoint the user typed in. It is written even when false, so the two
710
+ // cases are distinguishable instead of relying on a missing key.
711
+ if (known !== undefined) out.push(`known = ${known ? 'true' : 'false'}`);
712
+ // `catalog` records WHICH models.dev entry this provider maps to, so a renamed
713
+ // provider (e.g. "anthropic-work") still resolves the real catalog id. Only
714
+ // meaningful when `known` is true.
715
+ if (catalog) out.push(`catalog = ${q(catalog)}`);
649
716
  fs.mkdirSync(path.dirname(file), { recursive: true });
650
717
  fs.writeFileSync(file, out.join('\n') + '\n', 'utf8');
651
718
  return file;
@@ -718,13 +785,246 @@ export function addModel(provider, modelId, opts = {}) {
718
785
  // size the context bar per model (not a global 128k).
719
786
  if (opts.contextLength) lines.push(`context_length = ${Number(opts.contextLength)}`);
720
787
  if (opts.maxTokens) lines.push(`max_tokens = ${Number(opts.maxTokens)}`);
788
+ // Per-model output limit (from models.dev `limit.output`, or a provider's
789
+ // `max_output_tokens`). Its own key so it is not confused with `max_tokens`,
790
+ // which some endpoints use for the same thing and others do not.
791
+ if (opts.maxOutputTokens) lines.push(`max_output_tokens = ${Number(opts.maxOutputTokens)}`);
792
+ if (opts.reasoning !== undefined && opts.reasoning !== null) lines.push(`reasoning = ${opts.reasoning ? 'true' : 'false'}`);
793
+ // Thinking levels, from models.dev `reasoning_options` (or a user's own list).
794
+ if (Array.isArray(opts.efforts) && opts.efforts.length) {
795
+ lines.push(`efforts = [ ${opts.efforts.map((e) => `"${String(e).replace(/"/g, '\\"')}"`).join(', ')} ]`);
796
+ }
797
+ lines.push(...costLines(opts.cost));
721
798
  fs.mkdirSync(path.dirname(file), { recursive: true });
722
799
  fs.appendFileSync(file, '\n' + lines.join('\n') + '\n', 'utf8');
723
800
  return file;
724
801
  }
725
802
 
803
+ // ---- pricing ----------------------------------------------------------------
804
+ // Four flat keys rather than a nested table, because the TOML writer here is
805
+ // hand-rolled and a nested `[models."k".cost]` table would have to be tracked
806
+ // through three separate writers. Prices are USD per 1M tokens, which is the unit
807
+ // models.dev publishes and the unit the cost readout divides by.
808
+ const COST_KEYS = {
809
+ input: 'cost_input',
810
+ output: 'cost_output',
811
+ cacheRead: 'cost_cache_read',
812
+ cacheWrite: 'cost_cache_write',
813
+ };
814
+
815
+ /** `{ input, output, cacheRead, cacheWrite }` USD-per-1M -> TOML lines. */
816
+ function costLines(cost) {
817
+ if (!cost || typeof cost !== 'object') return [];
818
+ const out = [];
819
+ for (const [field, key] of Object.entries(COST_KEYS)) {
820
+ const n = Number(cost[field]);
821
+ if (Number.isFinite(n) && n > 0) out.push(`${key} = ${n}`);
822
+ }
823
+ return out;
824
+ }
825
+
826
+ /**
827
+ * Convert a models.dev catalog `cost` object into our shape, USD per 1M tokens.
828
+ *
829
+ * ZERO IS A PRICE. The catalog marks a free model as `{input: 0, output: 0}` — 637
830
+ * entries do — and there is no `free` flag to look for. Treating 0 as "missing" (the
831
+ * `n > 0` test) turned every free model into "no pricing", and the readout then fell
832
+ * through to a guess. So the test is `>= 0` and a zero is carried through as a real
833
+ * price: `usageCost` multiplies out to exactly 0 and the readout shows `$0.00`, which
834
+ * is true for a free service.
835
+ */
836
+ export function costFromCatalog(cost) {
837
+ if (!cost || typeof cost !== 'object') return undefined;
838
+ const pick = (v) => { const n = Number(v); return Number.isFinite(n) && n >= 0 ? n : undefined; };
839
+ const out = {
840
+ input: pick(cost.input),
841
+ output: pick(cost.output ?? cost.reasoning),
842
+ cacheRead: pick(cost.cache_read ?? cost.cacheRead),
843
+ cacheWrite: pick(cost.cache_write ?? cost.cacheWrite),
844
+ };
845
+ return Object.values(out).some((v) => v !== undefined) ? out : undefined;
846
+ }
847
+
848
+ /**
849
+ * A stored model entry's pricing, or null when the model has none.
850
+ *
851
+ * Falls back to the ALREADY-FETCHED models.dev catalog. Every model entry written
852
+ * before pricing existed has no cost keys, and re-adding each provider to backfill
853
+ * them is a migration the user should not have to perform — the catalog that
854
+ * produced the entry in the first place already knows the price. The fallback is
855
+ * free (a Map lookup into a memoized object) and silent: when the catalog has not
856
+ * been fetched this session there is simply no fallback, and the readout says the
857
+ * price is unknown rather than guessing.
858
+ */
859
+ export function modelCost(cfg, modelKeyName) {
860
+ const models = (cfg && cfg.raw && cfg.raw.models) || {};
861
+ const key = modelKeyName || (cfg && cfg.model);
862
+ const entry = models[key] || {};
863
+ // `>= 0`, like costFromCatalog: a model the user priced at ZERO is priced, and must
864
+ // `>= 0`, like costFromCatalog: a model the user priced at ZERO is priced, and must
865
+ // not fall through to the catalog (which would charge it someone else's rate).
866
+ const pick = (v) => { const n = Number(v); return Number.isFinite(n) && n >= 0 ? n : undefined; };
867
+ const stored = {
868
+ input: pick(entry.cost_input ?? entry.costInput),
869
+ output: pick(entry.cost_output ?? entry.costOutput),
870
+ cacheRead: pick(entry.cost_cache_read ?? entry.costCacheRead),
871
+ cacheWrite: pick(entry.cost_cache_write ?? entry.costCacheWrite),
872
+ };
873
+ if (Object.values(stored).some((v) => v !== undefined)) return stored;
874
+ return catalogCostFor(cfg, entry, key);
875
+ }
876
+
877
+ // ---- catalog price lookup ----------------------------------------------------
878
+ // A model entry with no price keys of its own: look it up in models.dev by its EXACT
879
+ // provider bucket and model id. That is the whole rule.
880
+ //
881
+ // WHY THERE IS NO GUESSING BEYOND THAT. An unmatched model belongs to a provider
882
+ // models.dev does not list — a local proxy, a company endpoint, someone's free
883
+ // gateway — and there is NO WAY to know what it costs. Earlier versions tried to infer
884
+ // a price from the model id (a `deepseek-` prefix looked like DeepSeek's vendor entry;
885
+ // or the same bare name appearing under other providers, taking the most common rate).
886
+ // That produced a CONFIDENT, WRONG number rather than an honest gap: a FREE local
887
+ // proxy serving `workbuddy/deepseek-v4.1-flash` was billed DeepSeek's official rate,
888
+ // and the status line showed $2.57 for a service that charges nothing.
889
+ //
890
+ // An invented price is worse than a missing one. The readout omits the cost segment
891
+ // entirely when there is no price, which is visibly "unknown"; a wrong number is not
892
+ // detectable at all. A provider that resells under a different name can still be
893
+ // priced, but only by SAYING SO: `catalog = "<models.dev id>"` on the provider.
894
+ //
895
+ // The second lookup is the provider's own id inside the bucket: some endpoints report
896
+ // a model as `deepseek-ai/deepseek-flash` where the catalog keys it as `deepseek-flash`,
897
+ // and taking the last path segment matches that without matching a different model.
898
+ function catalogCostFor(cfg, entry, key) {
899
+ if (!catalogCache) return null;
900
+ const providerName = entry.provider || String(key || '').split('/')[0];
901
+ const prov = ((cfg && cfg.raw && cfg.raw.providers) || {})[providerName] || {};
902
+ // `catalog` records which models.dev entry a renamed provider maps to, so a gateway
903
+ // serving a vendor's models under its own name can be priced — because the user
904
+ // declared the mapping, not because we guessed it from the model's name.
905
+ const catalogId = prov.catalog || providerName;
906
+ const modelId = entry.model || String(key || '').split('/').slice(1).join('/');
907
+ const bucket = catalogCache[catalogId];
908
+ if (!bucket || !bucket.models) return null;
909
+ const hit = bucket.models[modelId] || bucket.models[String(modelId).split('/').pop()];
910
+ if (!hit) return null;
911
+ return costFromCatalog(hit.cost) || null;
912
+ }
913
+
914
+
915
+ /**
916
+ * USD spent for a token total, given pricing. `usage` is the normalised
917
+ * accumulator shape ({ input, output, cached, cacheWrite }), with `cached` being
918
+ * the part of `input` that was a cache READ — so it is billed at the cache price
919
+ * and the remainder at the input price.
920
+ *
921
+ * Returns null when the model has no pricing: an unknown model must report
922
+ * "unknown", never a made-up $0.00 that reads like "this was free".
923
+ */
924
+ export function usageCost(usage, cost) {
925
+ if (!usage || !cost) return null;
926
+ const per = (tokens, price) => (Number(tokens) || 0) / 1e6 * (Number(price) || 0);
927
+ const cached = Number(usage.cached) || 0;
928
+ const input = Number(usage.input) || 0;
929
+ // Cache READS are a subset of the reported input; bill the difference at the
930
+ // plain input rate. Without this the input side was billed twice.
931
+ const freshInput = Math.max(0, input - cached);
932
+ let total = per(freshInput, cost.input) + per(Number(usage.output) || 0, cost.output);
933
+ if (cached) total += per(cached, cost.cacheRead !== undefined ? cost.cacheRead : cost.input);
934
+ if (usage.cacheWrite) total += per(usage.cacheWrite, cost.cacheWrite !== undefined ? cost.cacheWrite : cost.input);
935
+ return total;
936
+ }
937
+
938
+
939
+ // Replace (or create) a single `[models."<key>"]` block. Unlike addModel, which is
940
+ // append-only and a no-op when the key exists, this REWRITES the block so the
941
+ // /model editor can change display name / context / output limits. A key passed as
942
+ // undefined or '' is OMITTED, so clearing a field removes it from the file rather
943
+ // than writing an empty value. Other tables are left untouched.
944
+ export function upsertModel(provider, modelId, opts = {}) {
945
+ const file = hncodeConfigFile();
946
+ let text = '';
947
+ try { text = fs.readFileSync(file, 'utf8'); } catch { /* new file */ }
948
+ const key = modelKey(provider, modelId);
949
+ const unq = (s) => String(s).replace(/\\(.)/g, '$1');
950
+ const modelRe = /^\s*\[models\.\s*"((?:[^"\\]|\\.)*)"\s*\]\s*$/;
951
+ const lines = text.split(/\r?\n/);
952
+ const out = [];
953
+ let skipping = false;
954
+ for (const ln of lines) {
955
+ const mm = modelRe.exec(ln);
956
+ if (mm) { skipping = unq(mm[1]) === key; if (skipping) continue; }
957
+ else if (/^\s*\[/.test(ln)) skipping = false;
958
+ if (!skipping) out.push(ln);
959
+ }
960
+ while (out.length && out[out.length - 1].trim() === '') out.pop();
961
+ out.push('');
962
+ const q = (v) => `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
963
+ out.push(`[models.${q(key)}]`);
964
+ out.push(`provider = ${q(provider)}`);
965
+ out.push(`model = ${q(modelId)}`);
966
+ if (opts.display_name) out.push(`display_name = ${q(opts.display_name)}`);
967
+ if (opts.contextLength) out.push(`context_length = ${Number(opts.contextLength)}`);
968
+ if (opts.maxTokens) out.push(`max_tokens = ${Number(opts.maxTokens)}`);
969
+ if (opts.maxOutputTokens) out.push(`max_output_tokens = ${Number(opts.maxOutputTokens)}`);
970
+ // Pricing, when the caller knows it. A blank means "leave the entry without
971
+ // prices", which is what an entry edited by hand in config.toml keeps.
972
+ out.push(...costLines(opts.cost));
973
+ fs.mkdirSync(path.dirname(file), { recursive: true });
974
+ fs.mkdirSync(path.dirname(file), { recursive: true });
975
+ fs.writeFileSync(file, out.join('\n') + '\n', 'utf8');
976
+ return file;
977
+ }
978
+
979
+ // Replace EVERY `[models."<provider>/*"]` block with the given list, persisting a
980
+ // provider's model set after a refresh. `entries` is an array of
981
+ // { id, display_name, contextLength, maxTokens, maxOutputTokens, reasoning, efforts }.
982
+ // Unlike upsertModel (one key) this is the bulk form: entries the provider no longer
983
+ // offers DISAPPEAR from the file, which is what makes Ctrl+R in /provider a real
984
+ // refresh rather than an in-memory-only illusion that the next launch undoes.
985
+ export function replaceProviderModels(provider, entries) {
986
+ const file = hncodeConfigFile();
987
+ let text = '';
988
+ try { text = fs.readFileSync(file, 'utf8'); } catch { /* new file */ }
989
+ const unq = (s) => String(s).replace(/\\(.)/g, '$1');
990
+ const modelRe = /^\s*\[models\.\s*"((?:[^"\\]|\\.)*)"\s*\]\s*$/;
991
+ const lines = text.split(/\r?\n/);
992
+ const out = [];
993
+ let skipping = false;
994
+ for (const ln of lines) {
995
+ const mm = modelRe.exec(ln);
996
+ if (mm) { skipping = unq(mm[1]).split('/')[0] === provider; if (skipping) continue; }
997
+ else if (/^\s*\[/.test(ln)) skipping = false;
998
+ if (!skipping) out.push(ln);
999
+ }
1000
+ while (out.length && out[out.length - 1].trim() === '') out.pop();
1001
+ const q = (v) => `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
1002
+ for (const e of (entries || [])) {
1003
+ const id = String(e.id || '').trim();
1004
+ if (!id) continue;
1005
+ out.push('');
1006
+ out.push(`[models.${q(modelKey(provider, id))}]`);
1007
+ out.push(`provider = ${q(provider)}`);
1008
+ out.push(`model = ${q(id)}`);
1009
+ if (e.display_name) out.push(`display_name = ${q(e.display_name)}`);
1010
+ if (e.contextLength) out.push(`context_length = ${Number(e.contextLength)}`);
1011
+ if (e.maxTokens) out.push(`max_tokens = ${Number(e.maxTokens)}`);
1012
+ if (e.maxOutputTokens) out.push(`max_output_tokens = ${Number(e.maxOutputTokens)}`);
1013
+ if (e.reasoning !== undefined && e.reasoning !== null) out.push(`reasoning = ${e.reasoning ? 'true' : 'false'}`);
1014
+ if (Array.isArray(e.efforts) && e.efforts.length) {
1015
+ out.push(`efforts = [ ${e.efforts.map((x) => `"${String(x).replace(/"/g, '\\"')}"`).join(', ')} ]`);
1016
+ }
1017
+ out.push(...costLines(e.cost));
1018
+ }
1019
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1020
+ fs.writeFileSync(file, out.join('\n') + (out.length ? '\n' : ''), 'utf8');
1021
+ return file;
1022
+ }
1023
+
726
1024
  function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
727
1025
 
1026
+
1027
+
728
1028
  // Fetch the model list from a provider's API. The base URL is used as given and
729
1029
  // we append only `/models` (so `https://host/v1` → `https://host/v1/models`).
730
1030
  // Anthropic sends x-api-key + anthropic-version. Returns [{id, display}] or [].
@@ -777,53 +1077,120 @@ export async function fetchModels({ baseUrl, apiKey, protocol, throwOnError = fa
777
1077
  } finally { clearTimeout(timer); }
778
1078
  }
779
1079
 
780
- // ---- thinking effort capability ----
1080
+ /**
1081
+ * Turn models.dev `reasoning_options` into a level list, or null when there is
1082
+ * nothing usable. This is the ONLY source of graded thinking levels — we never
1083
+ * derive a grade from the model id.
1084
+ *
1085
+ * The catalog shape is an array of typed entries:
1086
+ * { type: 'effort', values: ['low','medium','high'] } -> use `values`
1087
+ * { type: 'budget_tokens', min: 1024 } -> on/off
1088
+ * { type: 'toggle' } -> on/off
1089
+ * `none` in an effort list means "off"; it is normalised to 'off' and kept first.
1090
+ * A model may declare SEVERAL entries and they are not ordered, so the whole array
1091
+ * is scanned and an `effort` list wins over the plain toggle. Returns null (not [])
1092
+ * when the model declares no options, so the caller can tell "no catalog data" from
1093
+ * "explicitly no reasoning".
1094
+ */
1095
+ export function effortsFromReasoning(reasoningOptions) {
1096
+ const opts = Array.isArray(reasoningOptions) ? reasoningOptions : [];
1097
+ let graded = null;
1098
+ let toggle = false;
1099
+ // Read every entry before deciding. DeepSeek is what made this necessary: its
1100
+ // catalog data is `[{ type: 'toggle' }, { type: 'effort', values: [low, high,
1101
+ // max] }]`, and returning at the FIRST entry read only the toggle — so a model
1102
+ // with three grades showed a plain off/on and `max` was unreachable.
1103
+ for (const o of opts) {
1104
+ if (!o || typeof o !== 'object') continue;
1105
+ if (!graded && o.type === 'effort' && Array.isArray(o.values) && o.values.length) {
1106
+ const vals = o.values.map((v) => (String(v).toLowerCase() === 'none' ? 'off' : String(v).toLowerCase()));
1107
+ // De-dup and make sure 'off' is first when present.
1108
+ const uniq = [...new Set(vals)];
1109
+ uniq.sort((a, b) => (a === 'off' ? -1 : b === 'off' ? 1 : 0));
1110
+ graded = uniq;
1111
+ }
1112
+ if (o.type === 'budget_tokens' || o.type === 'toggle') toggle = true;
1113
+ }
1114
+ if (graded) {
1115
+ // A `toggle` beside the grades means thinking can also be switched off, which
1116
+ // the effort list does not always spell out itself.
1117
+ return toggle && !graded.includes('off') ? ['off', ...graded] : graded;
1118
+ }
1119
+ return toggle ? ['off', 'on'] : null;
1120
+ }
781
1121
 
782
1122
  // ---- thinking effort capability ----
783
1123
  // The set of selectable efforts for a model. Honours, in order:
784
- // 1. an explicit `efforts` / `reasoning_efforts` list on the model entry
785
- // 2. an explicit `reasoning = false` (model cannot think)
786
- // 3. a name heuristic for models known to expose graded effort
787
- // Returns [] when the model has no thinking control (then /model shows no
788
- // effort row and /effort reports it is unsupported).
789
- // Selectable thinking efforts for a model, mirroring kimi's `segmentsFor`:
790
- // * a model that declares `efforts` uses exactly those (plus a leading 'off'
791
- // unless it is `always_thinking`, which cannot be turned off)
792
- // * `reasoning = false` / `thinking = false` -> no control at all
793
- // * otherwise fall back to id heuristics: graded OpenAI models get
794
- // off/low/medium/high, Anthropic and everything else get off/on.
1124
+ // 1. an explicit `efforts` list on the model entry — this is what a catalog
1125
+ // install writes (from models.dev `reasoning_options`), and what a user can
1126
+ // also set by hand in config.toml (e.g. to name their own "max"/"xhigh")
1127
+ // 2. an explicit `reasoning = false` (model cannot think) -> no control
1128
+ // 3. `reasoning = true` with no declared levels -> the plain off/on toggle
1129
+ // Returns [] when the model has no thinking control (then /model shows no effort
1130
+ // row and /effort reports it is unsupported). Grades are NEVER guessed from the
1131
+ // model id: models.dev is the source of truth, and a model it does not know simply
1132
+ // gets the on/off toggle rather than an invented level list.
795
1133
  export function effortOptions(cfg, modelKeyName) {
796
1134
  const models = (cfg.raw && cfg.raw.models) || {};
797
1135
  const entry = models[modelKeyName || cfg.model] || {};
1136
+ // 1. A user-declared list wins outright (config.toml `efforts = [...]`), so a
1137
+ // user can name their own levels (max, xhigh, …) even for a model the catalog
1138
+ // does not know.
798
1139
  const declared = Array.isArray(entry.efforts) ? entry.efforts.filter(Boolean).slice() : [];
799
1140
  const alwaysOn = entry.always_thinking === true || entry.alwaysThinking === true;
800
- if (declared.length) return alwaysOn ? declared : (declared[0] === 'off' ? declared : ['off', ...declared]);
801
- if (alwaysOn) return ['on'];
802
- if (entry.reasoning === false || entry.thinking === false) return [];
803
- const id = String(entry.model || modelKeyName || cfg.innerModel || '').toLowerCase();
804
- // OpenAI-style graded efforts.
805
- if (/(^|\/)(gpt-5|o1|o3|o4)/.test(id) || /gpt-5|o1-|o3-|o4-/.test(id)) {
806
- return ['off', 'low', 'medium', 'high'];
1141
+ if (declared.length) {
1142
+ // Prepend 'off' unless the list already has it or thinking cannot be turned off.
1143
+ return alwaysOn || declared[0] === 'off' ? declared : ['off', ...declared];
807
1144
  }
808
- // Anthropic extended thinking is a budget, not a grade — expose on/off only.
809
- if (cfg.protocol === 'anthropic') return ['off', 'on'];
810
- // Default: thinking is on/off.
1145
+ // 2. An explicit `reasoning = false` / `thinking = false` means no control.
1146
+ if (entry.reasoning === false || entry.thinking === false) return [];
1147
+ if (alwaysOn) return ['on'];
1148
+ // 3. No declared levels. `reasoning = true` (supported) AND a missing field
1149
+ // (unknown — e.g. an entry created before the catalog stored efforts) both get
1150
+ // the plain off/on toggle. Grades are NEVER guessed from the model id: the
1151
+ // graded list is models.dev's to declare, stored as `efforts` when the provider
1152
+ // was added from the catalog. An entry with no reasoning field is UNKNOWN, not
1153
+ // "unsupported", so it must not lose its toggle.
811
1154
  return ['off', 'on'];
812
1155
  }
813
1156
 
1157
+ // Token budget for a NAMED grade, on a protocol that wants a number rather than a word.
1158
+ //
1159
+ // The grades the catalog declares are low/medium/high, and a user may also write their own
1160
+ // in config.toml (`efforts = ["xhigh"]`). A grade with no entry here has no honest budget to
1161
+ // send, so the field is omitted — the previous code fell back to 8000, which silently gave
1162
+ // every unknown grade the same strength as `on` and `medium`.
1163
+ const EFFORT_BUDGETS = { low: 2000, medium: 8000, high: 16000, max: 32000 };
1164
+
814
1165
  // Map a chosen effort to the wire value for the active protocol.
1166
+ //
1167
+ // `on` means "thinking on", NOT "medium". The two are different requests: a model may
1168
+ // support a plain toggle without any graded control, in which case there is no grade to
1169
+ // send and inventing one (`medium`) can be rejected outright by the provider. `on` therefore
1170
+ // sends the protocol's plain enable, or nothing at all.
815
1171
  export function effortWire(cfg, effort) {
816
1172
  if (!effort || effort === 'off') return null;
1173
+ const named = Object.prototype.hasOwnProperty.call(EFFORT_BUDGETS, effort);
1174
+
817
1175
  if (cfg.protocol === 'anthropic') {
818
- const budget = { on: 8000, low: 2000, medium: 8000, high: 16000, max: 32000 }[effort] || 8000;
819
- return { thinking: { type: 'enabled', budget_tokens: budget } };
1176
+ // `thinking: { type: 'enabled' }` IS the plain toggle; `budget_tokens` is optional and
1177
+ // only meaningful for a named grade. Omitting it lets the provider pick its own budget,
1178
+ // which is what "on" asked for.
1179
+ return named
1180
+ ? { thinking: { type: 'enabled', budget_tokens: EFFORT_BUDGETS[effort] } }
1181
+ : { thinking: { type: 'enabled' } };
820
1182
  }
821
- // OpenAI-compatible: `reasoning_effort`. "on" is not a valid grade, so map it
822
- // to "medium"; concrete grades pass through.
823
- const grade = effort === 'on' ? 'medium' : effort;
824
- return { reasoning_effort: grade };
1183
+
1184
+ // Responses API and the OpenAI-compatible shape both take a GRADE word, and neither has
1185
+ // a value meaning "on". So a named grade passes through, and `on` sends nothing — the
1186
+ // request carries whatever the provider does by default for that model, rather than a
1187
+ // grade the model may not accept.
1188
+ if (!named) return {};
1189
+ if (cfg.protocol === 'responses') return { reasoning: { effort } };
1190
+ return { reasoning_effort: effort };
825
1191
  }
826
1192
 
1193
+
827
1194
  function toTomlLine(k, v) {
828
1195
  if (typeof v === 'string') return `${k} = "${v.replace(/"/g, '\\"')}"`;
829
1196
  if (typeof v === 'boolean') return `${k} = ${v}`;
@@ -941,7 +1308,9 @@ export function resolveProvider(cfg, providerName, innerModel) {
941
1308
  const protocol = (prov.protocol) || cfg.protocol;
942
1309
  const inner = prov.model || (innerModel != null ? innerModel : cfg.innerModel);
943
1310
  const b = String(baseUrl || '').replace(/\/+$/, '');
944
- const endpoint = !b ? '' : (protocol === 'anthropic' ? `${b}/messages` : `${b}/chat/completions`);
1311
+ const endpoint = !b ? '' : (protocol === 'anthropic' ? `${b}/messages`
1312
+ : protocol === 'responses' ? `${b}/responses`
1313
+ : `${b}/chat/completions`);
945
1314
  // Provider switch may move the active model too; refresh the context window
946
1315
  // so the gauge reflects the newly active model, not the old one.
947
1316
  const maxContextTokens = contextForModel(cfg, cfg.model);
@@ -962,4 +1331,4 @@ export function resolveModelArg(cfg, modelName) {
962
1331
  return { ...resolveProvider(cfg, provider, inner), model: modelName, maxContextTokens };
963
1332
  }
964
1333
 
965
- export default { resolveConfig, saveConfig, effectiveSnapshot, resolveProvider, resolveModelArg, addProvider, removeProvider, addModel, fetchModels, fetchCatalog, modelKey, modelLabel, bareModelId, rememberModel, hncodeConfigFile };
1334
+ export default { resolveConfig, saveConfig, effectiveSnapshot, resolveProvider, resolveModelArg, addProvider, removeProvider, addModel, upsertModel, replaceProviderModels, fetchModels, fetchCatalog, modelKey, modelLabel, bareModelId, rememberModel, hncodeConfigFile, effortsFromReasoning, effortOptions, costFromCatalog, modelCost, usageCost };