klyro 1.0.16 → 1.0.18

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 (92) hide show
  1. package/dist/agent/anthropic-adapter.js +26 -1
  2. package/dist/agent/provider-adapter.d.ts +7 -0
  3. package/dist/agent/provider-adapter.js +26 -1
  4. package/dist/agent/runtime.d.ts +14 -0
  5. package/dist/agent/runtime.js +59 -22
  6. package/dist/chat.js +16 -56
  7. package/dist/checkpoints/store.d.ts +15 -1
  8. package/dist/checkpoints/store.js +7 -2
  9. package/dist/cli/args.d.ts +7 -0
  10. package/dist/cli/args.js +10 -0
  11. package/dist/cli/completion.js +0 -3
  12. package/dist/cli/doctor.d.ts +13 -0
  13. package/dist/cli/doctor.js +133 -1
  14. package/dist/cli/errors.js +1 -1
  15. package/dist/cli/eval.d.ts +32 -0
  16. package/dist/cli/eval.js +83 -6
  17. package/dist/cli/markdown.js +1 -1
  18. package/dist/cli/repl.js +13 -24
  19. package/dist/cli/run.d.ts +37 -0
  20. package/dist/cli/run.js +81 -9
  21. package/dist/cli/update.js +7 -1
  22. package/dist/context/accounting.js +1 -1
  23. package/dist/context/klyro-md.d.ts +11 -0
  24. package/dist/context/klyro-md.js +59 -4
  25. package/dist/context/level7.d.ts +4 -0
  26. package/dist/context/level7.js +20 -1
  27. package/dist/context/project-map.js +14 -13
  28. package/dist/eval/baseline.d.ts +47 -0
  29. package/dist/eval/baseline.js +55 -0
  30. package/dist/eval/harness.d.ts +14 -2
  31. package/dist/eval/harness.js +42 -4
  32. package/dist/index.js +57 -3
  33. package/dist/persistence/store.js +2 -2
  34. package/dist/policy/approval.js +3 -0
  35. package/dist/policy/path-guard.d.ts +9 -4
  36. package/dist/policy/path-guard.js +47 -23
  37. package/dist/providers/endpoints.js +8 -0
  38. package/dist/providers/model-info.d.ts +1 -0
  39. package/dist/providers/model-info.js +19 -0
  40. package/dist/providers.js +6 -2
  41. package/dist/renderers/terminal.d.ts +7 -0
  42. package/dist/renderers/terminal.js +52 -0
  43. package/dist/repl.d.ts +9 -0
  44. package/dist/repl.js +24 -5
  45. package/dist/shared/index.d.ts +1 -0
  46. package/dist/shared/index.js +1 -0
  47. package/dist/shared/proxy.d.ts +37 -0
  48. package/dist/shared/proxy.js +360 -0
  49. package/dist/tools/agent/task-list.d.ts +1 -1
  50. package/dist/tools/fs/apply-patch.js +4 -4
  51. package/dist/tools/fs/edit-file.js +2 -4
  52. package/dist/tools/fs/list-dir.js +1 -1
  53. package/dist/tools/fs/multi-edit.js +2 -2
  54. package/dist/tools/fs/read-file.js +1 -1
  55. package/dist/tools/fs/write-file.js +2 -2
  56. package/dist/tools/git/git-blame.d.ts +5 -0
  57. package/dist/tools/git/git-blame.js +22 -0
  58. package/dist/tools/git/git-diff.js +3 -20
  59. package/dist/tools/git/git-log.js +5 -19
  60. package/dist/tools/git/git-show.d.ts +5 -0
  61. package/dist/tools/git/git-show.js +22 -0
  62. package/dist/tools/git/git-status.js +4 -21
  63. package/dist/tools/git/run-git.d.ts +5 -0
  64. package/dist/tools/git/run-git.js +18 -0
  65. package/dist/tools/lsp/diagnostics.js +2 -3
  66. package/dist/tools/plan/todo-write.d.ts +1 -1
  67. package/dist/tools/registry.js +4 -0
  68. package/dist/tools/search/dependencies.d.ts +4 -4
  69. package/dist/tools/search/dependencies.js +1 -1
  70. package/dist/tools/search/glob.js +9 -4
  71. package/dist/tools/search/grep.js +11 -4
  72. package/dist/tools/search/ignore.d.ts +8 -0
  73. package/dist/tools/search/ignore.js +49 -0
  74. package/dist/tools/search/recent-files.js +1 -1
  75. package/dist/tools/search/search-files.js +11 -4
  76. package/dist/tools/web/web-fetch.js +2 -1
  77. package/dist/tools/web/web-search.js +3 -1
  78. package/dist/tui/app.js +117 -10
  79. package/dist/tui/app.test.js +65 -4
  80. package/dist/tui/measure.js +1 -0
  81. package/dist/tui/mouse.d.ts +7 -5
  82. package/dist/tui/mouse.js +9 -6
  83. package/dist/tui/scroll-flow.test.js +0 -1
  84. package/dist/tui/transcript-commands.d.ts +14 -0
  85. package/dist/tui/transcript-commands.js +31 -0
  86. package/dist/util/log.d.ts +19 -10
  87. package/dist/util/log.js +87 -52
  88. package/dist/verification/classify.d.ts +1 -1
  89. package/dist/verification/classify.js +2 -2
  90. package/dist/verification/engine.js +0 -6
  91. package/dist/verification/scoped.js +6 -4
  92. package/package.json +6 -1
package/dist/tui/app.js CHANGED
@@ -7,6 +7,8 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
7
7
  import { Box, Text, useInput, useStdout } from 'ink';
8
8
  import Spinner from 'ink-spinner';
9
9
  import { execFileSync } from 'node:child_process';
10
+ import { readFileSync } from 'node:fs';
11
+ import * as path from 'node:path';
10
12
  import { TuiApprovalBridge, ApprovalModal } from './approval.js';
11
13
  import { parse as parseSlash, suggestCommands } from '../cli/slash/parser.js';
12
14
  import { loadCustomCommands, listCompletableFiles } from '../cli/slash/custom.js';
@@ -33,6 +35,40 @@ function Header({ cwd, model, version, width }) {
33
35
  const showLinks = width >= 120;
34
36
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, flexShrink: 0, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: tokens.colors.accent, children: ["KLYRO v", version] }), showLinks ? _jsx(Text, { color: tokens.colors.dim, children: "\u2502 /help /config /clear /exit" }) : null] }), _jsxs(Text, { color: tokens.colors.dim, children: [model, " \u00B7 ", cwd] }), branch ? _jsxs(Text, { color: tokens.colors.dim, children: ["\u2387 ", branch] }) : null] }));
35
37
  }
38
+ /**
39
+ * 5.5c — compact todo checklist. Live `plan` state (`todo_write` →
40
+ * `plan_update` events → `updatePlan`) wins; when it is empty, best-effort
41
+ * read of `.klyro/plans/todos.json` so a reloaded TUI still shows the list.
42
+ */
43
+ const TODO_STATUSES = ['pending', 'in_progress', 'done', 'failed', 'skipped'];
44
+ function loadTodosFromDisk(cwd) {
45
+ try {
46
+ const raw = readFileSync(path.join(cwd, '.klyro', 'plans', 'todos.json'), 'utf-8');
47
+ const arr = JSON.parse(raw);
48
+ if (!Array.isArray(arr))
49
+ return [];
50
+ return arr
51
+ .filter((t) => typeof t.title === 'string' && t.title.length > 0)
52
+ .map((t, i) => ({
53
+ id: typeof t.id === 'string' ? t.id : `disk-${i}`,
54
+ title: t.title,
55
+ status: TODO_STATUSES.includes(t.status)
56
+ ? t.status
57
+ : 'pending',
58
+ }));
59
+ }
60
+ catch {
61
+ return [];
62
+ }
63
+ }
64
+ function TodoChecklist({ plan, cwd }) {
65
+ // Disk fallback only when live state is empty (never masks live updates).
66
+ const disk = useMemo(() => (plan.length > 0 ? [] : loadTodosFromDisk(cwd)), [plan.length, cwd]);
67
+ const items = plan.length > 0 ? plan : disk;
68
+ if (items.length === 0)
69
+ return null;
70
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", items.filter((p) => p.status === 'done').length, "/", items.length] })] }), items.slice(0, 8).map((p, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", i + 1, ". ", redact(p.title)] })] }, p.id)))] }));
71
+ }
36
72
  function verbForTool(name) {
37
73
  if (name === 'read_file')
38
74
  return 'Read';
@@ -229,6 +265,14 @@ export function App(props) {
229
265
  setHistory((prev) => (prev[prev.length - 1] === v ? prev : [...prev.slice(-99), v]));
230
266
  setHistIdx(null);
231
267
  }, []);
268
+ // Ctrl+R reverse-i-search over input history (1.4): {query, saved, idx}
269
+ // with idx into newest-first matches (-1 = query only, nothing chosen).
270
+ const [histSearch, setHistSearch] = useState(null);
271
+ const searchMatches = useCallback((q) => {
272
+ const query = q.toLowerCase();
273
+ const matches = (query === '' ? history : history.filter((h) => h.toLowerCase().includes(query)));
274
+ return matches.slice().reverse();
275
+ }, [history]);
232
276
  // design.md §18: Ctrl+C cancels the run first, exits on second press.
233
277
  const ctrlCArmed = useRef(false);
234
278
  useEffect(() => {
@@ -484,7 +528,6 @@ export function App(props) {
484
528
  out.push({ key: 'tail:queued', desc: { kind: 'queued', count: queuedInputs.length }, groupIndex: null, tail: 'queued' });
485
529
  }
486
530
  return out;
487
- // eslint-disable-next-line react-hooks/exhaustive-deps
488
531
  }, [grouped, plan, status.status, queuedInputs.length, expandedGroups, width]);
489
532
  const blockKeys = useMemo(() => blocks.map((b) => b.key), [blocks]);
490
533
  const blockHeights = useMemo(() => blocks.map((b) => cache.heightFor(b.key, b.desc, width)), [blocks, width, cache]);
@@ -762,6 +805,56 @@ export function App(props) {
762
805
  setVimCursor(null);
763
806
  return;
764
807
  }
808
+ // Ctrl+R reverse-i-search over input history (1.4). While active, typing
809
+ // filters newest-first, ↑/↓ (or repeated Ctrl+R) cycle matches, Enter
810
+ // accepts into the buffer, Esc/Ctrl+C restores the saved input.
811
+ if (histSearch && !awaitingApproval) {
812
+ // idx into newest-first matches; -1 shows the raw query text.
813
+ const applyQuery = (query, idx) => {
814
+ const m = searchMatches(query);
815
+ const i = m.length === 0 ? -1 : Math.max(-1, Math.min(idx, m.length - 1));
816
+ setHistSearch({ query, saved: histSearch.saved, idx: i });
817
+ setInput(i === -1 ? query : (m[i] ?? query));
818
+ setVimCursor(null);
819
+ };
820
+ if (key.escape || (key.ctrl && inputStr === 'c')) {
821
+ setInput(histSearch.saved);
822
+ setHistIdx(null);
823
+ setHistSearch(null);
824
+ setVimCursor(null);
825
+ return;
826
+ }
827
+ if (key.return) {
828
+ setHistSearch(null);
829
+ setHistIdx(null);
830
+ setVimCursor(null);
831
+ return;
832
+ }
833
+ if ((key.upArrow && !key.shift && !key.ctrl) || (key.ctrl && (inputStr === 'p' || inputStr === 'r'))) {
834
+ applyQuery(histSearch.query, histSearch.idx + 1); // older (clamped)
835
+ return;
836
+ }
837
+ if ((key.downArrow && !key.shift && !key.ctrl) || (key.ctrl && inputStr === 'n')) {
838
+ applyQuery(histSearch.query, histSearch.idx - 1); // newer, -1 → query
839
+ return;
840
+ }
841
+ if (key.backspace || key.delete) {
842
+ applyQuery(histSearch.query.slice(0, -1), 0);
843
+ return;
844
+ }
845
+ if (!key.ctrl && !key.meta && inputStr) {
846
+ applyQuery(histSearch.query + inputStr, 0);
847
+ return;
848
+ }
849
+ return;
850
+ }
851
+ if (key.ctrl && inputStr === 'r' && !awaitingApproval && vimMode === 'insert') {
852
+ setHistSearch({ query: '', saved: input, idx: -1 });
853
+ setInput('');
854
+ setHistIdx(null);
855
+ setVimCursor(null);
856
+ return;
857
+ }
765
858
  // Scroll keys (work in any mode, including while running).
766
859
  // scroll.md §6 flow: Keyboard → getTranscriptCommand → handle.
767
860
  // design.md §11: PageUp/Ctrl+U half-up, PageDown/Ctrl+D half-down,
@@ -988,6 +1081,19 @@ export function App(props) {
988
1081
  return;
989
1082
  }
990
1083
  }
1084
+ // Ctrl+L (1.4): clear the input buffer if typing, else jump to the live
1085
+ // tail. Never touches the transcript — use /clear for that.
1086
+ if (key.ctrl && inputStr === 'l') {
1087
+ if (input.trim() !== '') {
1088
+ setInput('');
1089
+ setVimCursor(null);
1090
+ setHistIdx(null);
1091
+ }
1092
+ else if (isFullscreen && maxTop > 0) {
1093
+ commands.jumpBottom();
1094
+ }
1095
+ return;
1096
+ }
991
1097
  if (status.status === 'running') {
992
1098
  // design.md §18: first Ctrl+C cancels the run, second quits.
993
1099
  if (key.ctrl && inputStr === 'c') {
@@ -1055,7 +1161,7 @@ export function App(props) {
1055
1161
  // ↑/↓ are history keys, always (§8.3): ↑ browses older prompts
1056
1162
  // newest-first (empty history → no-op), ↓ browses back newer. Neither
1057
1163
  // ever scrolls the transcript — scrolling is PgUp/PgDn, Ctrl+U/D/B/F,
1058
- // Shift+↑/↓, Home/End, Space, or the wheel (KLYRO_MOUSE=1). Ctrl+P /
1164
+ // Shift+↑/↓, Home/End, Space, or the wheel. Ctrl+P /
1059
1165
  // Ctrl+N are escape-free aliases for the same history moves.
1060
1166
  const wantHistPrev = (key.upArrow && !key.shift && !key.ctrl) || (key.ctrl && inputStr === 'p');
1061
1167
  const wantHistNext = (key.downArrow && !key.shift && !key.ctrl) || (key.ctrl && inputStr === 'n');
@@ -1121,7 +1227,6 @@ export function App(props) {
1121
1227
  });
1122
1228
  // Single source of truth: package.json via version.ts — never hardcoded.
1123
1229
  const ver = props.version ?? readVersion();
1124
- const rule = g('rule').repeat(Math.max(10, width - 2));
1125
1230
  // Single cost/context source: model-aware registry rates + window.
1126
1231
  const cost = estimateCost(status.model, status.usageInput, status.usageOutput);
1127
1232
  const totalTokens = status.usageInput + status.usageOutput;
@@ -1129,12 +1234,14 @@ export function App(props) {
1129
1234
  const ctxPct = totalTokens > 0 ? Math.round((totalTokens / ctxWindow) * 100) : 0;
1130
1235
  // Narrow terminals: compact hints so the status bar never wraps mid-word.
1131
1236
  // An armed first-Esc surfaces here so the operator knows the second cancels.
1132
- const baseHints = escArmed && status.status === 'running'
1133
- ? 'press esc again to cancel'
1134
- : width < 90
1135
- ? status.status === 'running' ? 'ctrl+c stop · enter queue' : 'enter send · / commands'
1136
- : status.status === 'running' ? 'ctrl+c to stop · enter to queue · ctrl+o expand' : transcript.length === 0 ? 'shift+tab to cycle · ↑/↓ for history · / for commands' : 'enter to send · shift+enter newline · @ to attach';
1137
- const hints = maxTop > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
1237
+ const baseHints = histSearch !== null
1238
+ ? `reverse-i-search \`${histSearch.query}\` · type to filter · ↑/↓ cycle · enter accept · esc cancel`
1239
+ : escArmed && status.status === 'running'
1240
+ ? 'press esc again to cancel'
1241
+ : width < 90
1242
+ ? status.status === 'running' ? 'ctrl+c stop · enter queue' : 'enter send · / commands'
1243
+ : status.status === 'running' ? 'ctrl+c to stop · enter to queue · ctrl+o expand' : transcript.length === 0 ? 'shift+tab to cycle · ↑/↓ for history · / for commands' : 'enter to send · shift+enter newline · @ to attach';
1244
+ // I1 structural guard:
1138
1245
  // I1 structural guard: the frame can never exceed terminal rows. Even if a
1139
1246
  // child mis-measures, the root clips — the input/status stay on screen and
1140
1247
  // Ink's cursor math can't corrupt into overlapping text.
@@ -1216,5 +1323,5 @@ export function App(props) {
1216
1323
  if (it.kind === 'diff')
1217
1324
  return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, color: tokens.colors.soft, children: it.summary ?? 'Diff' }), it.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [_jsx(Text, { color: tokens.colors.soft, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { wrap: "wrap", color: l.kind === 'add' ? tokens.colors.ok : l.kind === 'remove' ? tokens.colors.err : tokens.colors.dim, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', redact(l.text)] }, j)))] }, i)))] }, it.id));
1218
1325
  return null;
1219
- }) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc \u00D72 to cancel)" }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan && plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", i + 1, ". ", redact(p.title)] })] }, p.id)))] })) : null, showQueued && queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", redact(q.slice(0, 60)), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen && !atBottom ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && badgeLabel(atBottom, pendingNew, status.status !== 'running') ? (_jsx(Box, { justifyContent: "center", flexShrink: 0, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ', badgeLabel(atBottom, pendingNew, status.status !== 'running'), ' '] }) })) : null, slashSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [slashSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " /", s.name, " \u2014 ", s.hint] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, slashSuggest.length === 0 && pathSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [pathSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " ", s.name] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, _jsx(ApprovalModal, { bridge: bridge }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), vimMode === 'normal' ? _jsx(Text, { color: tokens.colors.warn, bold: true, children: "--NORMAL-- " }) : null, _jsx(InputWithCursor, { input: input, cursor: vimCursor })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Text, { color: tokens.colors.dim, children: [status.status === 'running' ? (_jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " working \u00B7 "] })) : null, baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [maxTop > 0 && isFullscreen ? `⇅ ${topRow}/${maxTop} · ` : '', cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
1326
+ }) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc \u00D72 to cancel)" }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan ? _jsx(TodoChecklist, { plan: plan, cwd: props.cwd }) : null, showQueued && queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", redact(q.slice(0, 60)), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen && !atBottom ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && badgeLabel(atBottom, pendingNew, status.status !== 'running') ? (_jsx(Box, { justifyContent: "center", flexShrink: 0, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ', badgeLabel(atBottom, pendingNew, status.status !== 'running'), ' '] }) })) : null, slashSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [slashSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " /", s.name, " \u2014 ", s.hint] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, slashSuggest.length === 0 && pathSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [pathSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " ", s.name] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, _jsx(ApprovalModal, { bridge: bridge }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), vimMode === 'normal' ? _jsx(Text, { color: tokens.colors.warn, bold: true, children: "--NORMAL-- " }) : null, _jsx(InputWithCursor, { input: input, cursor: vimCursor })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Text, { color: tokens.colors.dim, children: [status.status === 'running' ? (_jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " working \u00B7 "] })) : null, baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [maxTop > 0 && isFullscreen ? `⇅ ${topRow}/${maxTop} · ` : '', cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
1220
1327
  }
@@ -215,9 +215,9 @@ describe('App', () => {
215
215
  });
216
216
  it('pressing End re-engages follow-tail and reveals new content', async () => {
217
217
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
218
- await new Promise((r) => setTimeout(r, 100));
218
+ await new Promise((r) => setTimeout(r, 200));
219
219
  stdin.write(KEY_HOME);
220
- await new Promise((r) => setTimeout(r, 100));
220
+ await new Promise((r) => setTimeout(r, 200));
221
221
  const g = globalThis;
222
222
  g.__klyroAppAppend({
223
223
  id: 'late-1',
@@ -229,7 +229,7 @@ describe('App', () => {
229
229
  expect(lastFrame() ?? '').not.toMatch(/LATE-1-tag/);
230
230
  // End re-engages follow-tail and shows the new content.
231
231
  stdin.write(KEY_END);
232
- await new Promise((r) => setTimeout(r, 100));
232
+ await new Promise((r) => setTimeout(r, 200));
233
233
  const frame = lastFrame() ?? '';
234
234
  expect(frame).toMatch(/LATE-1-tag/);
235
235
  });
@@ -321,6 +321,53 @@ describe('App', () => {
321
321
  await new Promise((r) => setTimeout(r, 30));
322
322
  expect(lastFrame() ?? '').toContain('zzsecond#');
323
323
  });
324
+ it('Ctrl+R reverse-searches history; Enter accepts, Esc restores (1.4)', async () => {
325
+ const onPrompt = vi.fn(async () => { });
326
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { } }));
327
+ stdin.write('fix login bug');
328
+ await new Promise((r) => setTimeout(r, 20));
329
+ stdin.write('\x0d');
330
+ await new Promise((r) => setTimeout(r, 50));
331
+ stdin.write('add logout button');
332
+ await new Promise((r) => setTimeout(r, 20));
333
+ stdin.write('\x0d');
334
+ await new Promise((r) => setTimeout(r, 50));
335
+ expect(onPrompt).toHaveBeenCalledTimes(2);
336
+ // Search for 'logout' + Enter accepts the match into the buffer
337
+ // (no submit); typing '!' must extend it in the input line only.
338
+ stdin.write('\x12');
339
+ await new Promise((r) => setTimeout(r, 30));
340
+ stdin.write('logout');
341
+ await new Promise((r) => setTimeout(r, 30));
342
+ stdin.write('\x0d');
343
+ await new Promise((r) => setTimeout(r, 30));
344
+ stdin.write('!');
345
+ await new Promise((r) => setTimeout(r, 30));
346
+ expect(lastFrame() ?? '').toContain('add logout button!');
347
+ // Esc cancels a fresh search and restores the pre-search buffer.
348
+ stdin.write('\x12');
349
+ await new Promise((r) => setTimeout(r, 30));
350
+ stdin.write('zzz');
351
+ await new Promise((r) => setTimeout(r, 30));
352
+ stdin.write('\x1b');
353
+ await new Promise((r) => setTimeout(r, 30));
354
+ stdin.write('?');
355
+ await new Promise((r) => setTimeout(r, 30));
356
+ expect(lastFrame() ?? '').not.toContain('zzz?');
357
+ expect(onPrompt).toHaveBeenCalledTimes(2); // searching never submits
358
+ });
359
+ it('Ctrl+L clears a non-empty input without submitting (1.4)', async () => {
360
+ const onPrompt = vi.fn(async () => { });
361
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { } }));
362
+ stdin.write('scratch that');
363
+ await new Promise((r) => setTimeout(r, 20));
364
+ stdin.write('\x0c');
365
+ await new Promise((r) => setTimeout(r, 30));
366
+ stdin.write('?');
367
+ await new Promise((r) => setTimeout(r, 30));
368
+ expect(lastFrame() ?? '').not.toContain('scratch that');
369
+ expect(onPrompt).not.toHaveBeenCalled();
370
+ });
324
371
  it('/c shows top-6 suggestions and Tab completes', async () => {
325
372
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { } }));
326
373
  stdin.write('/c');
@@ -448,7 +495,7 @@ describe('App', () => {
448
495
  await waitForAbsent(lastFrame, /MSG-24-tag/);
449
496
  await waitForMatch(lastFrame, /MSG-14-tag/);
450
497
  handle.runTranscriptCommand('messages_first');
451
- let frame = await waitForMatch(lastFrame, /MSG-00-tag/);
498
+ const frame = await waitForMatch(lastFrame, /MSG-00-tag/);
452
499
  expect(frame).not.toMatch(/MSG-24-tag/);
453
500
  handle.runTranscriptCommand('messages_last');
454
501
  await waitForMatch(lastFrame, /MSG-24-tag/);
@@ -710,6 +757,20 @@ describe('App', () => {
710
757
  expect(f2() ?? '').toContain(readVersion());
711
758
  expect(f2() ?? '').not.toContain('0.1.27');
712
759
  });
760
+ it('shows the todo checklist when plan updates arrive (5.5c)', async () => {
761
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { } }));
762
+ const g = globalThis;
763
+ for (let i = 0; i < 40 && !g.__klyroAppPlan; i++)
764
+ await new Promise((r) => setTimeout(r, 25));
765
+ expect(g.__klyroAppPlan).toBeDefined();
766
+ g.__klyroAppPlan?.([
767
+ { id: '1', title: 'Read files', status: 'done' },
768
+ { id: '2', title: 'Edit code', status: 'in_progress' },
769
+ ]);
770
+ const frame = await waitForMatch(lastFrame, /Read files/);
771
+ expect(frame).toMatch(/Plan/);
772
+ expect(frame).toMatch(/Edit code/);
773
+ });
713
774
  it('Shift+Up / Shift+Down scroll by one line', async () => {
714
775
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
715
776
  await new Promise((r) => setTimeout(r, 50));
@@ -17,6 +17,7 @@
17
17
  * plan block = 1 + min(8, steps) + 1 margin
18
18
  * thinking/queue = content lines + 1 margin
19
19
  */
20
+ // eslint-disable-next-line no-control-regex -- intentional: measuring visible width requires skipping SGR sequences
20
21
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
21
22
  function isWide(cp) {
22
23
  return ((cp >= 0x1100 && cp <= 0x115f) ||
@@ -37,11 +37,13 @@ export declare const MOUSE_DISABLE = "\u001B[?1000l\u001B[?1006l";
37
37
  /**
38
38
  * Whether the TUI may request terminal mouse reporting (wheel scrolling).
39
39
  *
40
- * Default OFF: with button reporting enabled the terminal routes
41
- * selection clicks and right-click paste to the app (which swallows them),
42
- * so native select-to-copy and right-click-paste break. Native selection
43
- * works out of the box; set `KLYRO_MOUSE=1` to opt into wheel scrolling
44
- * (Shift+drag still selects natively in most terminals).
40
+ * Default ON (opencode parity): the wheel scrolls the chat out of the box.
41
+ * Trade-off, stated plainly: with button reporting enabled the terminal
42
+ * routes plain selection clicks and right-clicks to the app (which drops
43
+ * them), so select-to-copy and right-click paste need Shift+drag /
44
+ * Shift+right-click in most terminals instead. Set `KLYRO_MOUSE=0` to opt
45
+ * out (fully native selection) and scroll with PgUp/PgDn, Ctrl+U/D,
46
+ * Shift+↑/↓, Home/End, or Space instead.
45
47
  */
46
48
  export declare function isMouseReportingEnabled(env?: Readonly<Record<string, string | undefined>>): boolean;
47
49
  export declare const PASTE_START = "\u001B[200~";
package/dist/tui/mouse.js CHANGED
@@ -76,6 +76,7 @@ export class MouseFilter {
76
76
  const tail = n - i;
77
77
  if (tail <= 5) {
78
78
  const rest = buf.subarray(i).toString('latin1');
79
+ // eslint-disable-next-line no-control-regex -- intentional: detecting split SGR/X10 mouse-sequence prefixes
79
80
  if (/^\x1b\[<$/.test(rest) || /^\x1b\[<[\d;]+$/.test(rest) || /^\x1b\[M.{0,2}$/.test(rest)) {
80
81
  this.pending = buf.subarray(i);
81
82
  break;
@@ -95,14 +96,16 @@ export const MOUSE_DISABLE = '\x1b[?1000l\x1b[?1006l';
95
96
  /**
96
97
  * Whether the TUI may request terminal mouse reporting (wheel scrolling).
97
98
  *
98
- * Default OFF: with button reporting enabled the terminal routes
99
- * selection clicks and right-click paste to the app (which swallows them),
100
- * so native select-to-copy and right-click-paste break. Native selection
101
- * works out of the box; set `KLYRO_MOUSE=1` to opt into wheel scrolling
102
- * (Shift+drag still selects natively in most terminals).
99
+ * Default ON (opencode parity): the wheel scrolls the chat out of the box.
100
+ * Trade-off, stated plainly: with button reporting enabled the terminal
101
+ * routes plain selection clicks and right-clicks to the app (which drops
102
+ * them), so select-to-copy and right-click paste need Shift+drag /
103
+ * Shift+right-click in most terminals instead. Set `KLYRO_MOUSE=0` to opt
104
+ * out (fully native selection) and scroll with PgUp/PgDn, Ctrl+U/D,
105
+ * Shift+↑/↓, Home/End, or Space instead.
103
106
  */
104
107
  export function isMouseReportingEnabled(env = process.env) {
105
- return env.KLYRO_MOUSE === '1';
108
+ return env.KLYRO_MOUSE !== '0';
106
109
  }
107
110
  export const PASTE_START = '\x1b[200~';
108
111
  export const PASTE_END = '\x1b[201~';
@@ -34,7 +34,6 @@ describe('scroll flow diagnostics', () => {
34
34
  const { lastFrame } = render(_jsx(App, { ...PROPS, isFullscreen: true }));
35
35
  await tick(50);
36
36
  const frame = lastFrame() ?? '';
37
- // eslint-disable-next-line no-console
38
37
  console.log(`[diag] frame rows=${rowsOf(frame)} cols~${(frame.split('\n')[0] ?? '').length}`);
39
38
  expect(rowsOf(frame)).toBeLessThanOrEqual(32);
40
39
  });
@@ -32,3 +32,17 @@ export interface TranscriptKey {
32
32
  * Ctrl+Home → first · Ctrl+End → last · anything else → undefined.
33
33
  */
34
34
  export declare function getTranscriptCommand(input: string, key: TranscriptKey): TranscriptCommand | undefined;
35
+ /**
36
+ * 4.4c — `# note` command: a line starting with `# ` (hash + space) is a
37
+ * session note, not a prompt. Returns the note text, or undefined when the
38
+ * line is not a note (a lone `#` or `#nospace` stays a regular prompt).
39
+ */
40
+ export declare function parseSessionNote(line: string): string | undefined;
41
+ /**
42
+ * Handle one input line as a potential `# note`. When it is a note, the
43
+ * text is appended as session-note feedback through `append` (the same
44
+ * channel as other command feedback — the caller's queuedAppend) and
45
+ * persisted via `persist` when the caller has a session-file hook.
46
+ * Returns true when the line was consumed as a note.
47
+ */
48
+ export declare function handleHashNoteLine(line: string, append: (text: string) => void, persist?: (note: string) => void | Promise<void>): boolean;
@@ -30,3 +30,34 @@ export function getTranscriptCommand(input, key) {
30
30
  return 'messages_last';
31
31
  return undefined;
32
32
  }
33
+ /**
34
+ * 4.4c — `# note` command: a line starting with `# ` (hash + space) is a
35
+ * session note, not a prompt. Returns the note text, or undefined when the
36
+ * line is not a note (a lone `#` or `#nospace` stays a regular prompt).
37
+ */
38
+ export function parseSessionNote(line) {
39
+ if (!line.startsWith('# '))
40
+ return undefined;
41
+ const note = line.slice(2).trim();
42
+ if (!note)
43
+ return undefined;
44
+ return note;
45
+ }
46
+ /**
47
+ * Handle one input line as a potential `# note`. When it is a note, the
48
+ * text is appended as session-note feedback through `append` (the same
49
+ * channel as other command feedback — the caller's queuedAppend) and
50
+ * persisted via `persist` when the caller has a session-file hook.
51
+ * Returns true when the line was consumed as a note.
52
+ */
53
+ export function handleHashNoteLine(line, append, persist) {
54
+ const note = parseSessionNote(line);
55
+ if (note === undefined)
56
+ return false;
57
+ append(`[note] ${note}`);
58
+ try {
59
+ void persist?.(note);
60
+ }
61
+ catch { /* best-effort only */ }
62
+ return true;
63
+ }
@@ -1,12 +1,21 @@
1
1
  /**
2
- * 1.5 — Logging (pino JSON to ~/.klyro/logs/klyro-YYYY-MM-DD.log, 14-day rotation, secret redaction)
3
- * Minimal implementation: JSONL append, redaction via secret-redactor, --debug pretty tee.
2
+ * Structured logging (1.5): pino JSON logs with secret redaction.
3
+ *
4
+ * - Destination: `KLYRO_LOG_DIR/klyro.log` (falls back to `~/.klyro`), with
5
+ * the same 2 MiB rotate-to-`.1` cap as the TUI debug log.
6
+ * - Level from `KLYRO_LOG_LEVEL` (or `DEBUG`→debug); unknown values fall
7
+ * back to `info` instead of throwing.
8
+ * - Every string is passed through the secret redactor first, and pino's
9
+ * own `redact` paths cover common credential object shapes.
10
+ * - Under VITEST the logger is silent (level `silent`) so tests never touch
11
+ * the real log file.
12
+ * - The logger never throws: an unwritable destination degrades to silent.
4
13
  */
5
- export interface LogEntry {
6
- ts: string;
7
- level: 'debug' | 'info' | 'warn' | 'error';
8
- msg: string;
9
- data?: unknown;
10
- }
11
- export declare function log(entry: LogEntry): Promise<void>;
12
- export declare function logSync(entry: LogEntry): void;
14
+ import { type Logger } from 'pino';
15
+ /** Shared process logger. Never throws; silent when logging is unavailable. */
16
+ export declare function getLogger(): Logger;
17
+ /** For tests: drop the cached instance so env changes take effect. */
18
+ export declare function resetLoggerForTests(): void;
19
+ /** Log helpers that redact string payloads before they reach pino. */
20
+ export declare function logInfo(msg: string, data?: unknown): void;
21
+ export declare function logDebug(msg: string, data?: unknown): void;
package/dist/util/log.js CHANGED
@@ -1,75 +1,110 @@
1
1
  /**
2
- * 1.5 — Logging (pino JSON to ~/.klyro/logs/klyro-YYYY-MM-DD.log, 14-day rotation, secret redaction)
3
- * Minimal implementation: JSONL append, redaction via secret-redactor, --debug pretty tee.
2
+ * Structured logging (1.5): pino JSON logs with secret redaction.
3
+ *
4
+ * - Destination: `KLYRO_LOG_DIR/klyro.log` (falls back to `~/.klyro`), with
5
+ * the same 2 MiB rotate-to-`.1` cap as the TUI debug log.
6
+ * - Level from `KLYRO_LOG_LEVEL` (or `DEBUG`→debug); unknown values fall
7
+ * back to `info` instead of throwing.
8
+ * - Every string is passed through the secret redactor first, and pino's
9
+ * own `redact` paths cover common credential object shapes.
10
+ * - Under VITEST the logger is silent (level `silent`) so tests never touch
11
+ * the real log file.
12
+ * - The logger never throws: an unwritable destination degrades to silent.
4
13
  */
5
- import * as fs from 'node:fs/promises';
6
- import * as fsSync from 'node:fs';
7
- import * as path from 'node:path';
14
+ import * as fs from 'node:fs';
8
15
  import * as os from 'node:os';
16
+ import * as path from 'node:path';
17
+ import pino from 'pino';
9
18
  import { redact } from '../policy/secret-redactor.js';
10
- function getLogDir() {
11
- if (process.env.KLYRO_LOG_DIR)
12
- return process.env.KLYRO_LOG_DIR;
13
- const home = os.homedir() || process.cwd();
14
- return path.join(home, '.klyro', 'logs');
19
+ const ROTATE_BYTES = 2 * 1024 * 1024;
20
+ const LEVELS = new Set(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']);
21
+ function level() {
22
+ if (process.env.VITEST)
23
+ return 'silent';
24
+ const raw = (process.env.KLYRO_LOG_LEVEL ?? (process.env.DEBUG ? 'debug' : 'info')).toLowerCase();
25
+ return (LEVELS.has(raw) ? raw : 'info');
15
26
  }
16
- function getLogFile() {
17
- const dir = getLogDir();
18
- const date = new Date().toISOString().slice(0, 10);
19
- return path.join(dir, `klyro-${date}.log`);
27
+ function logFile() {
28
+ const dir = process.env.KLYRO_LOG_DIR ?? path.join(os.homedir() || process.cwd(), '.klyro');
29
+ return path.join(dir, 'klyro.log');
20
30
  }
21
- async function rotateLogs() {
22
- const dir = getLogDir();
31
+ function rotateBestEffort(file) {
23
32
  try {
24
- const files = await fs.readdir(dir);
25
- const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1000;
26
- for (const f of files) {
27
- if (!f.startsWith('klyro-') || !f.endsWith('.log'))
28
- continue;
29
- const fp = path.join(dir, f);
33
+ if (fs.statSync(file).size > ROTATE_BYTES) {
30
34
  try {
31
- const st = await fs.stat(fp);
32
- if (st.mtimeMs < cutoff)
33
- await fs.unlink(fp);
35
+ fs.rmSync(`${file}.1`, { force: true });
34
36
  }
35
37
  catch { /* ignore */ }
38
+ fs.renameSync(file, `${file}.1`);
36
39
  }
37
40
  }
38
- catch { /* ignore dir not exists */ }
41
+ catch { /* missing — fine */ }
39
42
  }
40
- let _rotateDone = false;
41
- export async function log(entry) {
42
- if (!_rotateDone) {
43
- _rotateDone = true;
44
- rotateLogs().catch(() => undefined);
43
+ let cached = null;
44
+ /** Shared process logger. Never throws; silent when logging is unavailable. */
45
+ export function getLogger() {
46
+ if (cached)
47
+ return cached;
48
+ try {
49
+ // Tests must never touch the real log file: silent logger, no destination.
50
+ if (process.env.VITEST) {
51
+ cached = pino({ level: 'silent' });
52
+ return cached;
53
+ }
54
+ const file = logFile();
55
+ try {
56
+ fs.mkdirSync(path.dirname(file), { recursive: true });
57
+ }
58
+ catch { /* ignore */ }
59
+ rotateBestEffort(file);
60
+ cached = pino({
61
+ level: level(),
62
+ redact: {
63
+ paths: ['apiKey', 'api_key', '*.apiKey', '*.api_key', '*.token', '*.password', '*.secret', 'headers.authorization', '*.authorization'],
64
+ censor: '[REDACTED]',
65
+ },
66
+ }, pino.destination({ dest: file, sync: true }));
45
67
  }
46
- const redactedMsg = redact(entry.msg);
47
- const redactedData = entry.data ? JSON.parse(redact(JSON.stringify(entry.data))) : undefined;
48
- const line = JSON.stringify({ ...entry, msg: redactedMsg, data: redactedData }) + '\n';
49
- const file = getLogFile();
68
+ catch {
69
+ try {
70
+ cached = pino({ level: 'silent' });
71
+ }
72
+ catch {
73
+ cached = { info: () => undefined, debug: () => undefined, error: () => undefined, warn: () => undefined };
74
+ }
75
+ }
76
+ return cached;
77
+ }
78
+ /** For tests: drop the cached instance so env changes take effect. */
79
+ export function resetLoggerForTests() {
80
+ cached = null;
81
+ }
82
+ /** Log helpers that redact string payloads before they reach pino. */
83
+ export function logInfo(msg, data) {
50
84
  try {
51
- await fs.mkdir(path.dirname(file), { recursive: true });
52
- await fs.appendFile(file, line, 'utf-8');
85
+ if (data === undefined)
86
+ getLogger().info(redact(msg));
87
+ else
88
+ getLogger().info(redactStructured(data), redact(msg));
53
89
  }
54
- catch { /* ignore */ }
55
- if (process.env.KLYRO_LOG_LEVEL === 'debug' || process.env.DEBUG) {
56
- const pretty = `[${entry.level}] ${entry.msg}${entry.data ? ' ' + JSON.stringify(entry.data) : ''}`;
57
- if (entry.level === 'error')
58
- process.stderr.write(pretty + '\n');
90
+ catch { /* logging never breaks the run */ }
91
+ }
92
+ export function logDebug(msg, data) {
93
+ try {
94
+ if (data === undefined)
95
+ getLogger().debug(redact(msg));
59
96
  else
60
- process.stdout.write(pretty + '\n');
97
+ getLogger().debug(redactStructured(data), redact(msg));
61
98
  }
99
+ catch { /* logging never breaks the run */ }
62
100
  }
63
- export function logSync(entry) {
64
- const redactedMsg = redact(entry.msg);
65
- const line = JSON.stringify({ ...entry, msg: redactedMsg, ts: entry.ts }) + '\n';
66
- const file = getLogFile();
101
+ function redactStructured(data) {
102
+ if (typeof data === 'string')
103
+ return redact(data);
67
104
  try {
68
- fsSync.mkdirSync(path.dirname(file), { recursive: true });
69
- fsSync.appendFileSync(file, line, 'utf-8');
105
+ return JSON.parse(redact(JSON.stringify(data)));
70
106
  }
71
- catch { /* ignore */ }
72
- if (process.env.KLYRO_LOG_LEVEL === 'debug') {
73
- process.stderr.write(`[${entry.level}] ${redactedMsg}\n`);
107
+ catch {
108
+ return '[unserializable]';
74
109
  }
75
110
  }