@hncode/hncode 0.7.0 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/config.js CHANGED
@@ -370,6 +370,27 @@ const b = baseUrl.replace(/\/+$/, '');
370
370
  || root.max_output_size
371
371
  || (modelMaxOutput > 0 ? modelMaxOutput : DEFAULTS.maxOutputTokens),
372
372
  );
373
+ // A NUMBER OR NOTHING: an unparseable value falls back to "not set", which means the provider decides.
374
+ const numOrNull = (v, min, max) => {
375
+ if (v == null || v === '') return null;
376
+ const n = Number(v);
377
+ if (!Number.isFinite(n)) return null;
378
+ return Math.max(min, Math.min(max, n));
379
+ };
380
+ // `sampling` gates the whole group. A provider that rejects `temperature` outright — several refuse it for
381
+ // reasoning models — needs a switch, not a magic value nobody can find.
382
+ const samplingOn = process.env.HNCODE_SAMPLING
383
+ ? !/^(0|false|no|off)$/i.test(process.env.HNCODE_SAMPLING)
384
+ : root.sampling !== false;
385
+ const temperature = samplingOn
386
+ ? numOrNull(process.env.HNCODE_TEMPERATURE ?? root.temperature, 0, 2)
387
+ : null;
388
+ const topP = samplingOn
389
+ ? numOrNull(process.env.HNCODE_TOP_P ?? root.top_p, 0, 1)
390
+ : null;
391
+ const presencePenalty = samplingOn
392
+ ? numOrNull(process.env.HNCODE_PRESENCE_PENALTY ?? root.presence_penalty, -2, 2)
393
+ : null;
373
394
  const reasoning = process.env.HNCODE_REASONING ? /^(1|true|yes)$/i.test(process.env.HNCODE_REASONING) : !!root.reasoning;
374
395
  const allowExternal = process.env.HNCODE_ALLOW_EXTERNAL === '1' || !!root.tool_allow_external_paths;
375
396
 
@@ -377,6 +398,11 @@ const b = baseUrl.replace(/\/+$/, '');
377
398
  model, provider: providerName, innerModel,
378
399
  baseUrl: b, endpoint, apiKey, protocol,
379
400
  maxContextTokens: maxContext, maxOutputTokens: maxOutput, reasoning,
401
+ // Sampling. `undefined`/null means "do not send it", which is what lets a provider apply its own
402
+ // default — and what keeps a rejection from becoming a hard failure.
403
+ temperature: temperature == null ? undefined : temperature,
404
+ topP: topP == null ? undefined : topP,
405
+ presencePenalty: presencePenalty == null ? undefined : presencePenalty,
380
406
  workspace: process.env.HNCODE_WORKSPACE || root.workspace || DEFAULTS.workspace,
381
407
  allowExternal,
382
408
  // Standing permission rules (see permissions.js). `[permissions]` in config.toml:
package/src/llm.js CHANGED
@@ -233,6 +233,8 @@ function authHeaders(cfg) {
233
233
  function buildBody(cfg, messages, streaming = true, opts = {}) {
234
234
  const common = { stream: streaming, max_tokens: cfg.maxOutputTokens };
235
235
  if (cfg.temperature != null) common.temperature = cfg.temperature;
236
+ if (cfg.topP != null) common.top_p = cfg.topP;
237
+ if (cfg.presencePenalty != null) common.presence_penalty = cfg.presencePenalty;
236
238
  // Ask for the token accounting on the FINAL stream frame. Without this the
237
239
  // OpenAI-compatible stream carries no `usage` at all, so the only token numbers
238
240
  // available were our own estimates (see /usage, which divided the transcript's
@@ -265,6 +267,9 @@ function buildBody(cfg, messages, streaming = true, opts = {}) {
265
267
  ...common, ...wire,
266
268
  };
267
269
  if (!body.tools || !body.tools.length) delete body.tools;
270
+ // NO PRESENCE PENALTY ON THIS PROTOCOL: it has no equivalent, and an unknown field is an error
271
+ // here rather than something quietly ignored.
272
+ delete body.presence_penalty;
268
273
  // Prompt caching: mark the stable prefix (tools + system + conversation head)
269
274
  // so the provider bills it as a cache READ instead of fresh input. The key is
270
275
  // the SESSION id, so it stays identical across turns and never thrashes.
@@ -287,6 +292,8 @@ function buildBody(cfg, messages, streaming = true, opts = {}) {
287
292
  };
288
293
  if (instructions) rbody.instructions = instructions;
289
294
  if (cfg.temperature != null) rbody.temperature = cfg.temperature;
295
+ if (cfg.topP != null) rbody.top_p = cfg.topP;
296
+ // No presence penalty on this protocol: sending it is an unknown-parameter error, not a hint.
290
297
  if (!opts.noTools) {
291
298
  const defsR = Object.prototype.hasOwnProperty.call(opts, 'tools')
292
299
  ? responsesToolDefs(opts.tools) : responsesToolDefs(llmToolsList);
@@ -125,6 +125,18 @@ export const SETTINGS_SCHEMA = [
125
125
  ui: { tab: 'model', group: 'Thinking', label: 'Context window', hint: 'Override the model window when the catalog is wrong; 0 means use it' } },
126
126
  { key: 'prompt_cache', ...bool(true),
127
127
  ui: { tab: 'model', group: 'Provider', label: 'Prompt caching', hint: 'Ask the provider to cache the request prefix' } },
128
+ { key: 'sampling', ...bool(true),
129
+ label: 'Send sampling parameters',
130
+ help: 'Off sends no temperature/top_p at all, for a provider that rejects them (several do, for reasoning models).' },
131
+ { key: 'temperature', ...str(''),
132
+ label: 'Temperature',
133
+ help: '0 is deterministic, 2 is wild. Empty leaves the provider default. Accepts 0-2 with one decimal.' },
134
+ { key: 'top_p', ...str(''),
135
+ label: 'Top P',
136
+ help: 'Nucleus sampling 0-1. Empty leaves the provider default. Usually set INSTEAD of temperature, not both.' },
137
+ { key: 'presence_penalty', ...str(''),
138
+ label: 'Presence penalty',
139
+ help: 'OpenAI-compatible only: -2 to 2, positive discourages repeating topics. Anthropic has no equivalent and never receives it.' },
128
140
 
129
141
  // ---- context ------------------------------------------------------------
130
142
  { key: 'auto_compact', ...bool(true),
package/src/tui.js CHANGED
@@ -187,6 +187,7 @@ export const COMMANDS = [
187
187
  { name: 'permission', desc: 'Select permission mode', priority: 100 },
188
188
  { name: 'settings', aliases: ['config'], desc: 'Browse and change every setting, grouped by area', priority: 100, argumentHint: '[tab|filter]' },
189
189
  { name: 'set', desc: 'Set one config value by key, or list what differs from the default', priority: 100, argumentHint: '[<key> [value]]' },
190
+ { name: 'sampling', desc: 'Show or set temperature, top_p and presence_penalty', priority: 100, argumentHint: '[on|off|temperature <n>|top_p <n>|presence_penalty <n>]' },
190
191
  { name: 'plan', desc: 'Toggle plan mode', priority: 100, argumentHint: '[on|off|clear]' },
191
192
  { name: 'websearch-api', desc: 'Edit the WebSearch API keys (one per line, tried in order)', priority: 96 },
192
193
  { name: 'focus', desc: 'Toggle Focus mode (minimal tools first, full tools after)', priority: 98, argumentHint: '[on|off]' },
@@ -8688,12 +8689,26 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8688
8689
  if (key === 'calm_mode') cfg.calmMode = v;
8689
8690
  if (key === 'swarm_mode') cfg.swarm = v;
8690
8691
  if (key === 'tool_allow_external_paths') cfg.allowExternal = v;
8692
+ if (key === 'sampling' && !v) {
8693
+ // Off means NONE of them is sent — not their defaults, which is what a provider rejecting them
8694
+ // would receive otherwise.
8695
+ cfg.temperature = undefined; cfg.topP = undefined; cfg.presencePenalty = undefined;
8696
+ }
8691
8697
  } else {
8692
8698
  cfg[key] = raw; cfg.raw[key] = raw;
8693
8699
  if (key === 'compact_threshold') cfg.compactThreshold = Number(raw) / 100;
8694
8700
  if (key === 'compact_keep_ratio') cfg.compactKeepRatio = Number(raw) / 100;
8695
8701
  if (key === 'trim_threshold') cfg.trimThreshold = Number(raw) / 100;
8696
8702
  if (key === 'trim_keep_ratio') cfg.trimKeepRatio = Number(raw) / 100;
8703
+ // SAMPLING: a NUMBER, or gone. The generic writer stores the typed string, and `llm.js` sends it
8704
+ // straight into the request — a provider given `"0.3"` rejects it or ignores it.
8705
+ if (key === 'temperature' || key === 'top_p' || key === 'presence_penalty') {
8706
+ const field = { temperature: 'temperature', top_p: 'topP', presence_penalty: 'presencePenalty' }[key];
8707
+ const n = raw === '' ? null : Number(raw);
8708
+ // Clearing the field removes the parameter, so the provider applies its own default — which is not
8709
+ // the same thing as sending 0.
8710
+ cfg[field] = n == null || !Number.isFinite(n) ? undefined : n;
8711
+ }
8697
8712
  if (key === 'max_context_tokens') cfg.maxContextTokens = Number(raw) || cfg.maxContextTokens;
8698
8713
  if (key === 'provider') cfg.provider = raw;
8699
8714
  if (key === 'model') { cfg.model = raw; state.modelLabel = raw; }
@@ -8942,6 +8957,42 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8942
8957
  }
8943
8958
 
8944
8959
 
8960
+
8961
+ case 'sampling': {
8962
+ const parts = String(raw || '').trim().split(/\s+/).filter(Boolean);
8963
+ const show = () => {
8964
+ const row = (label, v, note) => ` ${label.padEnd(18)} ${v == null ? '(provider default)' : v}${note ? ' ' + note : ''}`;
8965
+ sayPanel([
8966
+ 'Sampling parameters',
8967
+ '',
8968
+ row('send them', cfg.sampling === false ? 'off' : 'on', cfg.sampling === false ? 'nothing is sent' : ''),
8969
+ row('temperature', cfg.temperature),
8970
+ row('top_p', cfg.topP),
8971
+ row('presence_penalty', cfg.presencePenalty, 'OpenAI-compatible only'),
8972
+ '',
8973
+ 'Set with /sampling temperature 0.3, /sampling top_p 0.9, /sampling off',
8974
+ ].join('\n'));
8975
+ };
8976
+ if (!parts.length) { show(); return; }
8977
+ if (parts[0] === 'on' || parts[0] === 'off') {
8978
+ applySetting('sampling', parts[0] === 'on' ? 'true' : 'false');
8979
+ app(`Sampling: ${parts[0]}`);
8980
+ show();
8981
+ return;
8982
+ }
8983
+ const key = parts[0] === 'top-p' ? 'top_p' : parts[0] === 'presence-penalty' ? 'presence_penalty' : parts[0];
8984
+ if (!['temperature', 'top_p', 'presence_penalty'].includes(key)) {
8985
+ appErr('Usage: /sampling [on|off] | temperature <0-2> | top_p <0-1> | presence_penalty <-2-2>');
8986
+ return;
8987
+ }
8988
+ if (parts.length === 1) { show(); return; }
8989
+ // An empty value clears it: the field means "let the provider decide", and `0` is a real temperature.
8990
+ const value = parts.slice(1).join(' ').trim();
8991
+ if (!applySetting(key, value)) return;
8992
+ app(value === '' ? `${key} cleared` : `${key} = ${value}`);
8993
+ show();
8994
+ return;
8995
+ }
8945
8996
  case 'set': {
8946
8997
  // `/set <key> <value>` — the direct path, so any setting is reachable without
8947
8998
  // remembering which command owns it. `/set` alone lists what is changed.
@@ -20567,6 +20618,7 @@ function wheelStep(state, family) {
20567
20618
  hub.actions.setConfig = async (key, value) => {
20568
20619
  const ALLOWED = new Set([
20569
20620
  'calm_mode', 'auto_update', 'auto_compact', 'prompt_cache',
20621
+ 'temperature', 'top_p', 'presence_penalty', 'sampling',
20570
20622
  'tool_allow_external_paths', 'subagent_model', 'swarm_mode', 'theme',
20571
20623
  ]);
20572
20624
  const k = String(key || '');