koneck 2.128.36 → 2.128.38

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
@@ -4,7 +4,7 @@ import { Box, Static, Text, render, useApp, useInput, useStdin } from 'ink';
4
4
  import { execa } from 'execa';
5
5
  import chalk from 'chalk';
6
6
  import { resolveAgentConcurrency, toolsUnsupportedNotice } from './engine-facts.js';
7
- import { withSynchronizedFrames, probeSynchronizedOutput } from './frame-sync.js';
7
+ import { withSynchronizedFrames, withUsableSize, probeSynchronizedOutput } from './frame-sync.js';
8
8
  import { pacedInterval, startingInterval, floorFor, shouldDrawFrame } from './frame-pace.js';
9
9
  import { estimateCost, formatCost, rateText } from './pricing.js';
10
10
  import { generateSessionId, saveSession, listSessions, loadSession, relativeAge, renameSession, forkSession, archiveSession, findSession, } from './session.js';
@@ -48,6 +48,7 @@ const VERSION = (() => {
48
48
  import { startBus, listSessions as listBusSessions, resolveTarget, deliver, } from './session-bus.js';
49
49
  import { MODES as MODE_SPECS, modeSpec, decide, loadRules, saveRules, addRule, commandPrefixSuggestion, describeRule, ruleMatches, toolRisk, isReadOnlyCommand, } from './permissions.js';
50
50
  import { AskQueue } from './ask-queue.js';
51
+ import { contextLimitWithSource } from './context-limit.js';
51
52
  import { planSummary } from './plan.js';
52
53
  import { savePlan, plansDir, PLAN_CHOICES, PROCEED_PROMPT } from './plan-file.js';
53
54
  import { logoFor } from './logo.js';
@@ -2052,8 +2053,25 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
2052
2053
  liveToolRef.current.output = line;
2053
2054
  },
2054
2055
  onStreamActivity: (frames) => {
2056
+ /*
2057
+ * A keep-alive is not news, and only the first one changes anything on screen.
2058
+ *
2059
+ * A gateway looking for a backend sends content-free frames, several a second, for as long
2060
+ * as it takes. Each one asked for a repaint, so a turn waiting on a slow provider redrew
2061
+ * the interface two or three times a second to show a clock — measured against the live
2062
+ * session: 13KB a second written to the terminal and 13% of a core, two hours into a turn
2063
+ * that was still waiting. On a terminal that cannot composite a redraw, every one of those
2064
+ * is a visible flash.
2065
+ *
2066
+ * The first frame is worth painting: it is what turns "starting" into "the provider is
2067
+ * holding the line, no output yet". After that nothing it carries is new, and the heartbeat
2068
+ * — once a second, and only while nothing else is painting — is what says the session is
2069
+ * still alive.
2070
+ */
2071
+ const first = providerFramesRef.current === 0 && frames > 0;
2055
2072
  providerFramesRef.current = frames;
2056
- requestIncomingPaint();
2073
+ if (first)
2074
+ requestIncomingPaint(true);
2057
2075
  },
2058
2076
  onIncomplete: (reason) => { incompleteRef.current = reason; },
2059
2077
  onToolsUnsupported: (model) => {
@@ -2751,7 +2769,7 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
2751
2769
  * a keystroke.
2752
2770
  */
2753
2771
  if (prev !== cols)
2754
- redrawScreenRef.current?.();
2772
+ redrawScreenRef.current?.({ keepScrollback: true });
2755
2773
  return cols;
2756
2774
  });
2757
2775
  };
@@ -3209,7 +3227,17 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3209
3227
  * lives in the session rather than in the terminal's memory of it.
3210
3228
  */
3211
3229
  redrawScreenRef.current = redrawScreen;
3212
- function redrawScreen() {
3230
+ /**
3231
+ * Repaint the terminal from scratch.
3232
+ *
3233
+ * `keepScrollback` is the difference between the two callers. Ctrl+L is somebody asking for a
3234
+ * clean slate after something has gone wrong on screen, and a stranded frame that has scrolled
3235
+ * out of view is still in the buffer waiting to come back — so that one takes the scrollback
3236
+ * with it. A resize is not a request for anything: it re-flows the conversation because the
3237
+ * width changed, and throwing away everything the person had scrolled past would be a far
3238
+ * larger thing than they asked for.
3239
+ */
3240
+ function redrawScreen(opts = {}) {
3213
3241
  if (!process.stdout.isTTY)
3214
3242
  return;
3215
3243
  // Ink's own clear first. It erases the frame using its own count of it and then resets that
@@ -3219,7 +3247,7 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3219
3247
  // Then the screen, and the scrollback with it — a stranded frame that has scrolled out of view
3220
3248
  // is still in the buffer, and leaving it there means it comes back on the next scroll — and
3221
3249
  // home the cursor, which is where Ink will now draw.
3222
- process.stdout.write('\u001b[2J\u001b[3J\u001b[H');
3250
+ process.stdout.write(opts.keepScrollback ? '\u001b[2J\u001b[H' : '\u001b[2J\u001b[3J\u001b[H');
3223
3251
  // The transcript has just been erased along with everything else, so it has to be written
3224
3252
  // again — and writing it again is also what re-flows it to the current width.
3225
3253
  setStaticEpoch(n => n + 1);
@@ -3329,7 +3357,19 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3329
3357
  ...cfg,
3330
3358
  provider,
3331
3359
  model: meta.model || cfg.model,
3332
- baseURL: endpointFor(provider).url,
3360
+ /*
3361
+ * An endpoint the session was started with survives the resume.
3362
+ *
3363
+ * This re-resolved from the provider's saved endpoint unconditionally, which is right
3364
+ * when the resume changes provider and wrong when it does not: a session started against
3365
+ * an explicit `--base-url`, a company gateway, or a local runtime on another machine was
3366
+ * silently moved back to whatever the config file named. The request then went somewhere
3367
+ * the person had deliberately pointed away from, and the first sign of it was a rejected
3368
+ * credential from an endpoint they were not using.
3369
+ */
3370
+ baseURL: provider !== cfg.provider
3371
+ ? endpointFor(provider).url
3372
+ : (cfg.baseURL ?? endpointFor(provider).url),
3333
3373
  ...(typeof meta.requireApproval === 'boolean' ? { requireApproval: meta.requireApproval } : {}),
3334
3374
  };
3335
3375
  cfgRef.current = restoredCfg;
@@ -3359,14 +3399,58 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3359
3399
  setSessionId(id); // continue this session rather than forking a new one
3360
3400
  resumedIdRef.current = id;
3361
3401
  startedAtRef.current = meta.startedAt ?? startedAtRef.current;
3402
+ /*
3403
+ * The conversation as it was, tool calls included.
3404
+ *
3405
+ * Only prose was restored before, so a resumed session came back as a handful of stray
3406
+ * sentences with nothing between them — every file read, command and edit dropped on the
3407
+ * floor. Measured on a real saved session: eighteen messages, of which five were the
3408
+ * assistant calling tools and nine were the results, and seven survived the restore.
3409
+ * Reported as resuming "differently, as a summary".
3410
+ *
3411
+ * A tool result says whether it worked, so the marks are the ones the session showed at the
3412
+ * time rather than a row of hopeful ticks. There are no timings in the file — durations are
3413
+ * not saved — so each step is written as having taken no measurable time, which is honest:
3414
+ * this is a record of what happened, not a replay of how long it took.
3415
+ */
3416
+ const resultFor = new Map();
3417
+ for (const m of messages) {
3418
+ if (m.role !== 'tool')
3419
+ continue;
3420
+ const body = typeof m.content === 'string' ? m.content : '';
3421
+ resultFor.set(String(m.tool_call_id ?? ''), {
3422
+ // The engine writes failures into the result body; nothing else records the outcome.
3423
+ ok: !/^\s*(error|failed|could not|cannot)\b/i.test(body),
3424
+ text: body,
3425
+ });
3426
+ }
3362
3427
  const restored = [{ role: 'header' }];
3363
3428
  for (const m of messages) {
3364
3429
  if (m.role === 'user' && typeof m.content === 'string' && !m.content.startsWith('[')) {
3365
3430
  restored.push({ role: 'user', text: m.content });
3431
+ continue;
3366
3432
  }
3367
- else if (m.role === 'assistant' && typeof m.content === 'string' && m.content.trim()) {
3433
+ if (m.role !== 'assistant')
3434
+ continue;
3435
+ if (typeof m.content === 'string' && m.content.trim()) {
3368
3436
  restored.push({ role: 'agent', text: m.content });
3369
3437
  }
3438
+ const calls = m.tool_calls;
3439
+ if (!calls?.length)
3440
+ continue;
3441
+ const steps = calls.map(c => {
3442
+ const name = c.function?.name ?? 'tool';
3443
+ const result = resultFor.get(String(c.id ?? ''));
3444
+ return {
3445
+ kind: 'tool',
3446
+ name,
3447
+ detail: toolDetail(name, c.function?.arguments ?? '{}'),
3448
+ startedAt: 0,
3449
+ endedAt: 0,
3450
+ ...(result ? { ok: result.ok } : {}),
3451
+ };
3452
+ });
3453
+ restored.push({ role: 'steps', steps });
3370
3454
  }
3371
3455
  setRows(restored);
3372
3456
  setStats({
@@ -3377,8 +3461,33 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3377
3461
  ` ${meta.turns} turns, ${meta.totalTokens.toLocaleString()} tokens\n` +
3378
3462
  ` originally: ${meta.provider}/${meta.model}\n` +
3379
3463
  ` posture: ${meta.mode ?? 'auto'} mode · ${meta.effort ?? 'medium'} effort · approval ${typeof meta.requireApproval === 'boolean' ? (meta.requireApproval ? 'on' : 'off') : 'current'}\n` +
3380
- ` task: ${meta.task.slice(0, 90)}\n\n` +
3381
- `The agent has the full history; carry on where it left off.`);
3464
+ ` task: ${meta.task.slice(0, 90)}`);
3465
+ /*
3466
+ * Pick up where it stopped, when there is somewhere to pick up from.
3467
+ *
3468
+ * Resuming used to restore everything and then wait, so the first thing anybody typed was
3469
+ * some version of "carry on" — retyping an instruction the session already held. Reported
3470
+ * against Claude Code, which resumes and continues.
3471
+ *
3472
+ * Only when the plan says there is work left. A session whose plan is finished has nothing
3473
+ * to continue and resuming it is almost always somebody looking at what happened, so
3474
+ * spending tokens on their behalf would be presumptuous. With unfinished steps it is the
3475
+ * opposite: the session exists precisely because that work is not done.
3476
+ *
3477
+ * `resumeContinues: false` turns it off, and it never fires for a session with no plan —
3478
+ * there would be nothing to say about what remained.
3479
+ */
3480
+ const unfinished = restoredPlan?.steps.filter(st => st.state !== 'done') ?? [];
3481
+ if (cfg.resumeContinues !== false && unfinished.length > 0) {
3482
+ const next = unfinished[0].text;
3483
+ addSystem(`Carrying on — ${unfinished.length} step${unfinished.length === 1 ? '' : 's'} `
3484
+ + `left, starting with "${next.slice(0, 70)}". Esc stops it.`);
3485
+ setTimeout(() => submitDraft('Continue the plan above from where it stopped. Do not restate it or start again — '
3486
+ + 'pick up the first step that is not done and carry on.'), 0);
3487
+ }
3488
+ else {
3489
+ addSystem('The agent has the full history; carry on where it left off.');
3490
+ }
3382
3491
  }
3383
3492
  catch (e) {
3384
3493
  addSystem(`Could not resume ${id}: ${e instanceof Error ? e.message : String(e)}`);
@@ -6949,7 +7058,22 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
6949
7058
  }
6950
7059
  }
6951
7060
  if (usagePane === 'context') {
6952
- const window = modelCatalog.find(m => m.id === cfg.model)?.contextLength ?? contextWindowFor(cfg.model);
7061
+ /*
7062
+ * Where the number came from, said out loud.
7063
+ *
7064
+ * The window drives compaction, and compaction is the difference between a session that
7065
+ * remembers what it found and one that keeps going back to re-read it. When the figure
7066
+ * is a guess from the model's name — which it is for most hosted models, because almost
7067
+ * none of them publish a context length in `/models` — the person needs to know that,
7068
+ * because they are the only one who can correct it.
7069
+ */
7070
+ const published = modelCatalog.find(m => m.id === cfg.model)?.contextLength;
7071
+ const guess = contextLimitWithSource(cfg.model, cfg.contextTokens, servesOwnWeights(cfg.provider));
7072
+ const window = published ?? guess.limit;
7073
+ const source = published !== undefined ? 'published by the provider'
7074
+ : guess.source === 'configured' ? 'set by you'
7075
+ : guess.source === 'known' ? 'known for this model'
7076
+ : 'assumed — this model publishes no window';
6953
7077
  lines.push('**Context window**', '');
6954
7078
  if (window) {
6955
7079
  // This is cumulative across the session, not a made-up claim about the next request.
@@ -6957,6 +7081,11 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
6957
7081
  // history, but only the provider can count the exact request after compaction.
6958
7082
  const used = stats.promptTokens;
6959
7083
  lines.push(...meter(Math.min(1, used / window), `${humanTokens(used)} session prompt total · ${humanTokens(window)} window · ${cfg.model}`));
7084
+ lines.push(`Window: ${window.toLocaleString()} tokens — ${source}.`);
7085
+ if (guess.source === 'assumed' && published === undefined) {
7086
+ lines.push('If the real one is larger, say so: `/config contextTokens <number>`. '
7087
+ + 'Compacting earlier than necessary makes a session forget what it just read.');
7088
+ }
6960
7089
  if (used / window > 0.7) {
6961
7090
  lines.push('Close to the window. `/clear` starts fresh,');
6962
7091
  lines.push('`/rewind` drops just the last exchange.');
@@ -7253,7 +7382,34 @@ export async function runInkChatMode(config) {
7253
7382
  * frame is a visible blink of the whole screen. 150ms at worst, once, on startup.
7254
7383
  */
7255
7384
  const synchronizedFrames = await probeSynchronizedOutput().catch(() => false);
7256
- const app = render(_jsx(App, { config: config, synchronizedFrames: synchronizedFrames, clearFrame: () => inkClear?.() }), { stdout: withSynchronizedFrames(process.stdout),
7385
+ /*
7386
+ * A clean screen to start on.
7387
+ *
7388
+ * KONECK opened wherever the shell had got to, so a session begun after an `npm install` sat
7389
+ * under a wall of its output with the banner halfway down the screen — it read as another
7390
+ * command that had printed something, rather than as a program that had taken over. Reported
7391
+ * exactly that way: "it fails to take over the terminal and not appear as if it's a command".
7392
+ *
7393
+ * `ESC[2J ESC[H` and nothing more. Deliberately not `ESC[3J`, which empties the scrollback too:
7394
+ * whatever was on screen a moment ago is still there to scroll back to, which is the difference
7395
+ * between clearing the screen and throwing away somebody's last command. Same sequence `clear`
7396
+ * uses, for the same reason.
7397
+ *
7398
+ * Written before Ink starts, so its idea of what it has drawn begins at zero and matches the
7399
+ * screen. Doing this after the first frame is what once left a dead copy of the interface
7400
+ * pinned below a working one.
7401
+ *
7402
+ * Not on the alternate screen, which arrives blank by definition, and not when the output is
7403
+ * not a terminal at all.
7404
+ */
7405
+ if (config.clearOnStart !== false && process.stdout.isTTY
7406
+ && initialAltScreen(config, synchronizedFrames) === false) {
7407
+ process.stdout.write('\u001b[2J\u001b[H');
7408
+ }
7409
+ const app = render(_jsx(App, { config: config, synchronizedFrames: synchronizedFrames, clearFrame: () => inkClear?.() }),
7410
+ // Sized first, then bracketed: Ink must never be handed a stream that
7411
+ // says it is zero rows tall. See withUsableSize.
7412
+ { stdout: withSynchronizedFrames(withUsableSize(process.stdout)),
7257
7413
  exitOnCtrlC: false, patchConsole: false });
7258
7414
  inkClear = app.clear;
7259
7415
  // The engine is imported on demand so the banner and the prompt are not waiting on the OpenAI