koneck 2.25.19 → 2.25.21

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
@@ -2,9 +2,9 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import React, { useState, useEffect, useRef } from 'react';
3
3
  import { Box, Static, Text, render, useApp, useInput, useStdin } from 'ink';
4
4
  import { execa } from 'execa';
5
- import { createAgentSession, buildClient, resolveAgentConcurrency } from './engine.js';
5
+ import { createAgentSession, buildClient, resolveAgentConcurrency, toolsUnsupportedNotice } from './engine.js';
6
6
  import { estimateCost, formatCost } from './pricing.js';
7
- import { generateSessionId, saveSession, listSessions, loadSession } from './session.js';
7
+ import { generateSessionId, saveSession, listSessions, loadSession, relativeAge } 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
10
  import { PROVIDERS, resolveProvider, getApiKey, addUserProvider, userProvidersPath } from './providers.js';
@@ -14,6 +14,7 @@ import { listWorkspaceEntries, filterEntries, activeReference } from './workspac
14
14
  import { isSlashCommand, findMentions, shortenMentions } from './mentions.js';
15
15
  import { bar, humanTokens, humanDuration, diffStatSince, modelInfoFrom, contextWindowFor, recordStatus, classifyResponse, stateLabel, readModelStatus, computeUiWidth, } from './usage.js';
16
16
  import { Markdown, Panel, panelWidth, safeCommitPoint, displayWidth, sliceToWidth } from './markdown-ink.js';
17
+ import { G } from './glyphs.js';
17
18
  import { fetchUpdateStatus, updateCommand } from './update-check.js';
18
19
  import { readFileSync, statSync } from 'fs';
19
20
  import { fileURLToPath } from 'url';
@@ -179,7 +180,7 @@ export function liveReplyLines(termRows) {
179
180
  const rows = termRows && termRows > 0 ? termRows : 24;
180
181
  return rows < 12 ? 1 : 2;
181
182
  }
182
- const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
183
+ const SPINNER = G.spinner;
183
184
  /**
184
185
  * A highlight that sweeps left to right through the word, so the label itself looks like it is
185
186
  * working rather than sitting still. The gap after the word's end pauses briefly between passes;
@@ -510,6 +511,15 @@ function App({ config: initialConfig }) {
510
511
  const thinkingRef = useRef({ chars: 0, at: 0 });
511
512
  /** Why the current turn stopped short, when it did. Cleared at the start of each turn. */
512
513
  const incompleteRef = useRef(null);
514
+ /** The model already reported as unable to call tools, so the notice is not repeated. */
515
+ const toolsWarnedRef = useRef(null);
516
+ /**
517
+ * The id to save under, mirrored in a ref.
518
+ *
519
+ * `sessionId` is state, so a save fired from inside a turn closure would use the value captured
520
+ * when the turn began — which is the pre-resume id if the two happen close together.
521
+ */
522
+ const resumedIdRef = useRef(null);
513
523
  /**
514
524
  * Text pasted into the composer, held aside so the prompt stays readable.
515
525
  *
@@ -668,6 +678,13 @@ function App({ config: initialConfig }) {
668
678
  },
669
679
  onStreamActivity: (frames) => setProviderFrames(frames),
670
680
  onIncomplete: (reason) => { incompleteRef.current = reason; },
681
+ onToolsUnsupported: (model) => {
682
+ // Said once per session, not once per turn — it is a property of the model, not an event.
683
+ if (toolsWarnedRef.current === model)
684
+ return;
685
+ toolsWarnedRef.current = model;
686
+ addSystem(toolsUnsupportedNotice(model));
687
+ },
671
688
  onThinking: (_text, totalChars) => {
672
689
  thinkingRef.current = { chars: totalChars, at: Date.now() };
673
690
  lastActivityRef.current = Date.now();
@@ -692,7 +709,10 @@ function App({ config: initialConfig }) {
692
709
  const [updatePane, setUpdatePane] = useState(null);
693
710
  const [updating, setUpdating] = useState('idle');
694
711
  const [updateLog, setUpdateLog] = useState('');
695
- const [sessionId] = useState(() => generateSessionId());
712
+ // Settable, because resuming has to adopt the restored session's id. Without that, every save
713
+ // after a resume wrote a new file and left the session you resumed frozen at its old state —
714
+ // which is exactly what made /resume look like it always returned to the old one.
715
+ const [sessionId, setSessionId] = useState(() => generateSessionId());
696
716
  /** Messages that arrived while a turn was running; drained when the agent goes idle. */
697
717
  const inboxRef = useRef([]);
698
718
  const busRef = useRef(null);
@@ -935,10 +955,15 @@ function App({ config: initialConfig }) {
935
955
  const firstUser = s?.messages.find((m) => m.role === 'user');
936
956
  const task = firstUser && typeof firstUser.content === 'string'
937
957
  ? firstUser.content.slice(0, 120) : '(chat session)';
938
- return saveSession(cfg.cwd, sessionId, {
939
- id: sessionId, task, provider: cfg.provider, model: cfg.model,
958
+ // Both of these come from the live session rather than from React state: a save fired at the
959
+ // end of a turn runs inside a closure that captured state as it was when the turn began, so
960
+ // reading `stats` here recorded turn counts one behind and, after a resume, the wrong id.
961
+ const id = resumedIdRef.current ?? sessionId;
962
+ const live = s?.stats ?? { turns: stats.turns, totalTokens: stats.totalTokens };
963
+ return saveSession(cfg.cwd, id, {
964
+ id, task, provider: cfg.provider, model: cfg.model,
940
965
  cwd: cfg.cwd, startedAt: new Date().toISOString(),
941
- turns: stats.turns, totalTokens: stats.totalTokens,
966
+ turns: live.turns, totalTokens: live.totalTokens,
942
967
  }, s?.messages ?? []);
943
968
  }
944
969
  /**
@@ -990,7 +1015,9 @@ function App({ config: initialConfig }) {
990
1015
  async function resumeSession(id) {
991
1016
  try {
992
1017
  const { meta, messages } = await loadSession(cfg.cwd, id);
993
- activeSession.p = createAgentSession({ ...cfg, silent: true, ...sessionOpts }, messages);
1018
+ activeSession.p = createAgentSession({ ...cfg, silent: true, ...sessionOpts }, messages, { turns: meta.turns, totalTokens: meta.totalTokens });
1019
+ setSessionId(id); // continue this session rather than forking a new one
1020
+ resumedIdRef.current = id;
994
1021
  const restored = [{ role: 'header' }];
995
1022
  for (const m of messages) {
996
1023
  if (m.role === 'user' && typeof m.content === 'string' && !m.content.startsWith('[')) {
@@ -1317,12 +1344,18 @@ function App({ config: initialConfig }) {
1317
1344
  }
1318
1345
  case '/clear':
1319
1346
  case '/reset':
1320
- case '/new':
1347
+ case '/new': {
1348
+ // A new id, or the autosave that now runs every turn would overwrite the session just
1349
+ // cleared with the empty conversation replacing it — turning a clear into a deletion.
1321
1350
  await resetSession();
1351
+ const fresh = generateSessionId();
1352
+ setSessionId(fresh);
1353
+ resumedIdRef.current = fresh;
1322
1354
  setRows([{ role: 'header' }]);
1323
1355
  setStats({ turns: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 });
1324
- addSystem('Conversation cleared.');
1356
+ addSystem('Conversation cleared. The previous session is kept; /resume lists it.');
1325
1357
  return;
1358
+ }
1326
1359
  case '/model': {
1327
1360
  if (!arg) {
1328
1361
  addSystem(`Current model: ${cfg.model}\nUsage: /model <name>`);
@@ -1481,10 +1514,13 @@ function App({ config: initialConfig }) {
1481
1514
  // With an id, restore straight away; without one, offer a picker.
1482
1515
  const pick = arg.trim();
1483
1516
  if (pick === '') {
1484
- openPicker('resume', saved.slice(0, 20).map(x => ({
1517
+ openPicker('resume', saved.slice(0, 20).map((x, i) => ({
1485
1518
  value: x.id,
1486
1519
  label: x.id,
1487
- desc: `${x.turns} turns ${x.totalTokens.toLocaleString()} tok ${x.task.slice(0, 44)}`,
1520
+ // Ordered by last write, so the first entry is the one you were last in — said out
1521
+ // loud rather than left to be inferred from a timestamp buried in the id.
1522
+ desc: `${i === 0 ? 'latest · ' : ''}${relativeAge(x.updatedAt)} ` +
1523
+ `${x.turns} turns ${x.totalTokens.toLocaleString()} tok ${x.task.slice(0, 36)}`,
1488
1524
  })));
1489
1525
  return;
1490
1526
  }
@@ -2343,6 +2379,10 @@ function App({ config: initialConfig }) {
2343
2379
  recovered: turnRecoveredRef.current,
2344
2380
  }, turnWordRef.current)[1] },
2345
2381
  ]);
2382
+ // Persisted on every turn, not just on /save, /clear and ctrl+c. Resuming restored
2383
+ // whichever of those three happened last, which for anyone who closes the terminal
2384
+ // normally was an old snapshot — the reported "always resumes the old one".
2385
+ void doSave().catch(() => { });
2346
2386
  // A run that stopped short says so, rather than letting the footer imply it finished.
2347
2387
  if (incompleteRef.current) {
2348
2388
  addSystem(incompleteRef.current);
@@ -2367,6 +2407,7 @@ function App({ config: initialConfig }) {
2367
2407
  sayRef.current = '';
2368
2408
  liveToolRef.current = null;
2369
2409
  flushAgents();
2410
+ void doSave().catch(() => { });
2370
2411
  setRows(prev => [
2371
2412
  ...prev,
2372
2413
  ...(trailing ? [{ role: 'steps', steps: [{ kind: 'say', text: trailing }] }] : []),
@@ -2446,14 +2487,14 @@ function App({ config: initialConfig }) {
2446
2487
  }
2447
2488
  const running = step.endedAt == null;
2448
2489
  const ms = (step.endedAt ?? Date.now()) - step.startedAt;
2449
- const glyph = running ? SPINNER[spinFrame] : step.ok === false ? '✖' : '✓';
2490
+ const glyph = running ? SPINNER[spinFrame] : step.ok === false ? G.fail : G.ok;
2450
2491
  const color = running ? AMBER : step.ok === false ? CRIMSON : GREEN;
2451
2492
  return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: color, children: glyph }), _jsx(Text, { color: AMBER, bold: true, children: step.name }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsx(Text, { color: MUTED, children: fmtElapsed(ms) })] }), step.detail !== '' && (step.diffs ?? []).length === 0 && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { color: DIM, children: "\u2514" }), _jsx(Text, { color: MUTED, children: step.detail })] })), running && step.output && (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { color: DIM, children: fitCells(step.output, Math.max(20, barWidth - 6)) }) })), (step.diffs ?? []).map((d, i) => renderDiff(d, i))] }, key));
2452
2493
  };
2453
2494
  return (_jsxs(Box, { flexDirection: "column", width: uiWidth, paddingX: 2, children: [showAnalytics && (_jsxs(Box, { borderStyle: "round", borderColor: AMBER, paddingX: 1, marginBottom: 1, flexDirection: "column", width: barWidth, children: [_jsx(Text, { color: AMBER, bold: true, children: "\u25C6 Session Analytics" }), _jsxs(Text, { color: MUTED, children: ["Turns : ", _jsx(Text, { color: INK, children: stats.turns })] }), _jsxs(Text, { color: MUTED, children: ["Prompt tok : ", _jsx(Text, { color: INK, children: stats.promptTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Output tok : ", _jsx(Text, { color: INK, children: stats.completionTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Total tok : ", _jsx(Text, { color: INK, children: stats.totalTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Model : ", _jsx(Text, { color: INK, children: modelShort }), " Provider: ", _jsx(Text, { color: INK, children: cfg.provider })] }), _jsx(Text, { color: DIM, children: "Tab to close" })] })), _jsx(Static, { items: rows, children: (row, index) => row.role === 'header' ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", alignItems: "center", marginTop: 1, marginBottom: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: "KONECK / SOFTWARE DELIVERY SYSTEM" }), _jsx(Text, { color: MUTED, children: "Deliberate engineering, with control at every change." })] }), _jsx(Panel, { width: barWidth, color: CYAN, title: "KONECK", children: [`MODEL: \`${modelShort}\` PROVIDER: \`${cfg.provider}\` WORKSPACE: \`${workspace}\``] })] }, index)) : row.role === 'user' ? (
2454
2495
  // Painted as a filled bar so the user's own words are the most findable thing
2455
2496
  // on screen when scrolling back through a long session.
2456
- _jsx(Box, { marginTop: 1, flexDirection: "column", children: wrapToWidth(row.text ?? '', barWidth - 2).map((line, li) => (_jsx(Text, { backgroundColor: USER_BG, color: USER_FG, bold: li === 0, children: (li === 0 ? '❯ ' : ' ') + fitCells(line, barWidth - 2) }, li))) }, index)) : row.role === 'error' ? (() => {
2497
+ _jsx(Box, { marginTop: 1, flexDirection: "column", children: wrapToWidth(row.text ?? '', barWidth - 2).map((line, li) => (_jsx(Text, { backgroundColor: USER_BG, color: USER_FG, bold: li === 0, children: (li === 0 ? `${G.caret} ` : ' ') + fitCells(line, barWidth - 2) }, li))) }, index)) : row.role === 'error' ? (() => {
2457
2498
  const lines = [row.text ?? '', '', 'Fix the above, then retry. /status shows the active provider and model.'];
2458
2499
  return (_jsx(Box, { marginTop: 1, children: _jsx(Panel, { width: panelWidth(lines, barWidth, 40), color: CRIMSON, title: "[!] ERROR", children: lines }) }, index));
2459
2500
  })() : row.role === 'system' ? (() => {
@@ -2464,7 +2505,7 @@ function App({ config: initialConfig }) {
2464
2505
  // content, so the right edge stepped in and out; and a box inside a box left
2465
2506
  // markdown tables needlessly narrow. A marker plus the content reads better and
2466
2507
  // cannot misalign, which is how Claude Code presents its own answers.
2467
- _jsxs(Box, { flexDirection: "column", marginTop: row.continued ? 0 : 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: row.continued ? ' ' : 'ꞰK' }), _jsx(Box, { flexDirection: "column", children: _jsx(Markdown, { text: row.text ?? '', width: barWidth - 3 }) })] }), row.tokens != null && (_jsxs(Text, { color: MUTED, children: [_jsx(Text, { color: GREEN, children: "* " }), row.done ? `${row.done} in ${fmtElapsed(row.elapsed ?? 0)}` : fmtElapsed(row.elapsed ?? 0), ' | ', row.tokens, " tokens", ' | ', cfg.provider, " ", modelShort] }))] }, index)) }), _jsx(Box, { flexDirection: "column", children: busy && (agentState === 'processing' || agentState === 'syncing') && (_jsxs(Box, { flexDirection: "column", children: [sayRef.current.trim() !== '' && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: replyStartedRef.current ? ' ' : 'ꞰK' }), _jsx(Box, { flexDirection: "column", children: tailLines(sayRef.current, liveReplyLines(termRows), replyWidth - 3)
2508
+ _jsxs(Box, { flexDirection: "column", marginTop: row.continued ? 0 : 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: row.continued ? ' ' : G.brand }), _jsx(Box, { flexDirection: "column", children: _jsx(Markdown, { text: row.text ?? '', width: barWidth - 3 }) })] }), row.tokens != null && (_jsxs(Text, { color: MUTED, children: [_jsx(Text, { color: GREEN, children: "* " }), row.done ? `${row.done} in ${fmtElapsed(row.elapsed ?? 0)}` : fmtElapsed(row.elapsed ?? 0), ' | ', row.tokens, " tokens", ' | ', cfg.provider, " ", modelShort] }))] }, index)) }), _jsx(Box, { flexDirection: "column", children: busy && (agentState === 'processing' || agentState === 'syncing') && (_jsxs(Box, { flexDirection: "column", children: [sayRef.current.trim() !== '' && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: replyStartedRef.current ? ' ' : G.brand }), _jsx(Box, { flexDirection: "column", children: tailLines(sayRef.current, liveReplyLines(termRows), replyWidth - 3)
2468
2509
  .split('\n').map((line, i) => _jsx(Text, { color: INK, children: line }, i)) })] })), liveToolRef.current && renderStep(liveToolRef.current, 1), queued.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: AMBER, children: "\u25AA" }), _jsxs(Text, { color: MUTED, children: [queued.length, " queued, will run when this finishes \u00B7 /aside to ask without waiting"] })] }), queued.map((q, i) => (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: fitCells(q.split('\n')[0] ?? '', Math.max(20, barWidth - 6), true) }) }, i)))] })), agents.length > 0 && (() => {
2469
2510
  const done = agents.filter(a => a.status !== 'running').length;
2470
2511
  const failed = agents.filter(a => a.status === 'failed').length;
@@ -2475,7 +2516,7 @@ function App({ config: initialConfig }) {
2475
2516
  const taskWidth = Math.max(16, barWidth - 34);
2476
2517
  return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, children: "\u25C6" }), _jsxs(Text, { color: INK, bold: true, children: [agents.length, " agents"] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsxs(Text, { color: done === agents.length ? GREEN : AMBER, children: [done, "/", agents.length, " complete"] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsxs(Text, { color: MUTED, children: [humanTokens(tokens), " tok"] }), _jsx(Text, { color: DIM, children: "\u00B7" }), _jsx(Text, { color: MUTED, children: fmtElapsed(Date.now() - started) }), failed > 0 && _jsxs(Text, { color: CRIMSON, children: ["\u00B7 ", failed, " failed"] })] }), agents.map(a => {
2477
2518
  const glyph = a.status === 'running' ? SPINNER[spinFrame]
2478
- : a.status === 'failed' ? '✖' : '✓';
2519
+ : a.status === 'failed' ? G.fail : G.ok;
2479
2520
  const color = a.status === 'running' ? AMBER
2480
2521
  : a.status === 'failed' ? CRIMSON : GREEN;
2481
2522
  const took = (a.endedAt ?? Date.now()) - a.startedAt;
@@ -2648,7 +2689,7 @@ function App({ config: initialConfig }) {
2648
2689
  lines.push('show a real bar here with no further work.');
2649
2690
  }
2650
2691
  lines.push('---');
2651
- lines.push('**←/→** switch tabs · **esc** to close');
2692
+ lines.push('**{left}/{right}** switch tabs · **esc** to close');
2652
2693
  return (_jsx(Box, { marginTop: 1, justifyContent: "flex-end", children: _jsx(Panel, { width: width, color: CYAN, title: "KONECK \u00B7 usage", children: lines }) }));
2653
2694
  })(), updatePane && (() => {
2654
2695
  const u = updatePane;
@@ -2716,7 +2757,7 @@ function App({ config: initialConfig }) {
2716
2757
  const header = item.group && item.group !== lastGroup ? item.group : null;
2717
2758
  lastGroup = item.group;
2718
2759
  const label = item.label.length > 34 ? item.label.slice(0, 31) + '...' : item.label;
2719
- return (_jsxs(Box, { flexDirection: "column", children: [header && _jsx(Text, { color: DIM, children: header }), _jsx(Text, { backgroundColor: selected ? CYAN : undefined, color: selected ? '#10222A' : INK, bold: selected, children: fitCells(`${selected ? ' ' : ' '}${fitCells(label, 36)}${item.current ? '(current) ' : ''}${item.desc}`, width) })] }, item.value + absolute));
2760
+ return (_jsxs(Box, { flexDirection: "column", children: [header && _jsx(Text, { color: DIM, children: header }), _jsx(Text, { backgroundColor: selected ? CYAN : undefined, color: selected ? '#10222A' : INK, bold: selected, children: fitCells(`${selected ? G.caret + ' ' : ' '}${fitCells(label, 36)}${item.current ? '(current) ' : ''}${item.desc}`, width) })] }, item.value + absolute));
2720
2761
  }), list.length > PICKER_ROWS && (_jsxs(Text, { color: DIM, children: ["\u2191\u2193 navigate \u00B7 enter select \u00B7 showing ", Math.max(0, start) + 1, "\u2013", Math.max(0, start) + shown.length, " of ", list.length] }))] }));
2721
2762
  })(), 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" })] })] })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: "\u276F " }), _jsx(Text, { color: INK, children: draft.slice(0, caret) }), busy
2722
2763
  ? _jsx(Text, { color: INK, children: draft.slice(caret) })