@hncode/hncode 0.4.2 → 0.5.0

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 (60) hide show
  1. package/README.md +1 -15
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +145 -33
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +175 -30
  13. package/src/config.js +376 -34
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +320 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/notebook.js +232 -0
  27. package/src/output-styles.js +183 -0
  28. package/src/patch.js +248 -0
  29. package/src/permissions.js +130 -1
  30. package/src/plan.js +3 -2
  31. package/src/plugin.js +611 -58
  32. package/src/prompt-presets.js +1 -1
  33. package/src/registry-browser.js +31 -13
  34. package/src/remote.js +20 -2
  35. package/src/rollout.js +151 -0
  36. package/src/row-cache.js +143 -0
  37. package/src/schedule.js +294 -0
  38. package/src/session-approvals.js +86 -0
  39. package/src/session.js +178 -8
  40. package/src/shell-safety.js +473 -0
  41. package/src/side-thread.js +240 -0
  42. package/src/skills.js +27 -0
  43. package/src/stream-buffer.js +350 -0
  44. package/src/subagent-control.js +160 -0
  45. package/src/teleport.js +260 -0
  46. package/src/term-caps.js +411 -0
  47. package/src/term.js +283 -8
  48. package/src/tools/agent-control.js +98 -0
  49. package/src/tools/agent-swarm.js +11 -2
  50. package/src/tools/agent.js +35 -1
  51. package/src/tools/diagnostics.js +175 -0
  52. package/src/tools/edit.js +25 -10
  53. package/src/tools/grep.js +62 -20
  54. package/src/tools/ignore.js +38 -14
  55. package/src/tools/index.js +10 -0
  56. package/src/tools/notebook.js +113 -0
  57. package/src/tools/read-media-file.js +10 -3
  58. package/src/tools/shell.js +34 -6
  59. package/src/tui.js +5219 -792
  60. package/src/web-public/app.js +7 -0
@@ -61,7 +61,7 @@ const RULES = {
61
61
  ],
62
62
  // Only for models with no dedicated reasoning channel.
63
63
  tagFallback: [
64
- 'Put all internal reasoning inside <think>...</think> and close the block before your answer. Reasoning must never appear outside it.',
64
+ 'Put all internal reasoning inside <|thinking|>...<|/thinking|> and close the block before your answer. Reasoning must never appear outside it.',
65
65
  ],
66
66
  },
67
67
  scope: [
@@ -108,8 +108,8 @@ export function rowsForTab(state, tabId) {
108
108
  name: r.name,
109
109
  label: r.name,
110
110
  description: r.description || (r.readable === false ? '(no SKILL.md — cannot install)' : '(no description)'),
111
- status: local.has(r.name) ? 'installed' : 'install',
112
- statusTone: local.has(r.name) ? 'dim' : 'primary',
111
+ status: local.has(r.name) ? 'Installed' : 'Not Install',
112
+ statusTone: local.has(r.name) ? 'ok' : 'warn',
113
113
  action: local.has(r.name) ? 'reinstall-skill' : 'install-skill',
114
114
  disabled: r.readable === false,
115
115
  }));
@@ -132,8 +132,8 @@ export function rowsForTab(state, tabId) {
132
132
  label: p.name + (p.kind === 'dir' ? '/' : ''),
133
133
  description: loadedNames.has(p.name)
134
134
  ? 'loaded in this session'
135
- : 'not loaded — restart hncode to load it',
136
- status: loadedNames.has(p.name) ? 'loaded' : 'restart',
135
+ : 'not loaded — Need Restart',
136
+ status: loadedNames.has(p.name) ? 'loaded' : 'Need Restart',
137
137
  statusTone: loadedNames.has(p.name) ? 'ok' : 'warn',
138
138
  action: 'remove-plugin',
139
139
  }));
@@ -143,8 +143,8 @@ export function rowsForTab(state, tabId) {
143
143
  name: r.name,
144
144
  label: r.name,
145
145
  description: local.has(r.name) ? 'already installed locally' : 'available in the repository',
146
- status: local.has(r.name) ? 'installed' : 'install',
147
- statusTone: local.has(r.name) ? 'dim' : 'primary',
146
+ status: local.has(r.name) ? 'Installed' : 'Not Install',
147
+ statusTone: local.has(r.name) ? 'ok' : 'warn',
148
148
  action: local.has(r.name) ? 'reinstall-plugin' : 'install-plugin',
149
149
  }));
150
150
  }
@@ -171,6 +171,12 @@ export function renderRegistryBrowser(state, cols, rows) {
171
171
 
172
172
  const out = [];
173
173
  const rule = C.border + '\u2500'.repeat(cols) + C.reset;
174
+ // Mouse targets for this frame. The browser is pure render + key, so it records
175
+ // its own hitboxes here and the caller (tui.js) maps mouse events onto them —
176
+ // exactly like the tasks browser does with `_taskHits`. Row is a FRAME row
177
+ // (0 = the top rule); the caller converts the 1-based screen row.
178
+ const hits = [];
179
+ state._regHits = hits;
174
180
  out.push(rule);
175
181
  out.push(fitExactly(C.cyan + C.bold + title + C.reset, cols));
176
182
  // The key hints stay visible while the fetch runs: the panel is usable during it
@@ -181,13 +187,21 @@ export function renderRegistryBrowser(state, cols, rows) {
181
187
  out.push('');
182
188
 
183
189
  // Tab strip, with the count of each tab's rows.
184
- // Tab strip, with the count of each tab's rows.
185
- out.push(tabs.map((t) => {
186
- const n = rowsForTab(state, t.id).length;
187
- return t.id === tab.id
188
- ? C.cyan + C.bold + `[${t.label} (${n})]` + C.reset
189
- : C.gray + ` ${t.label} (${n}) ` + C.reset;
190
- }).join(' '));
190
+ {
191
+ const stripRow = out.length;
192
+ let col = 0;
193
+ const segs = tabs.map((t) => {
194
+ const n = rowsForTab(state, t.id).length;
195
+ const text = t.id === tab.id ? `[${t.label} (${n})]` : ` ${t.label} (${n}) `;
196
+ const col0 = col;
197
+ col += visWidth(text) + 1; // +1 for the joining space
198
+ return { id: t.id, text, col0, col1: col0 + visWidth(text) - 1 };
199
+ });
200
+ out.push(segs.map((s) => (s.id === tab.id
201
+ ? C.cyan + C.bold + s.text + C.reset
202
+ : C.gray + s.text + C.reset)).join(' '));
203
+ for (const s of segs) hits.push({ row: stripRow, col0: s.col0, col1: s.col1, kind: 'regTab', tab: s.id });
204
+ }
191
205
  out.push('');
192
206
 
193
207
  // Body: each row is a label line plus its wrapped description, so the footer must be
@@ -218,7 +232,11 @@ export function renderRegistryBrowser(state, cols, rows) {
218
232
  const nameStyle = isSel ? (C.cyan + C.bold) : C.white;
219
233
  const badge = r.status ? ' ' + statusColor(r.statusTone) + r.status + C.reset : '';
220
234
  const dis = r.disabled ? C.gray : '';
235
+ const labelRow = out.length; // the clickable row for this item
221
236
  out.push(fitExactly(ptr + nameStyle + dis + r.label + C.reset + badge, cols));
237
+ // The whole label row is a click target: clicking selects the item (and a
238
+ // second click / the footer activates it), mirroring the tasks browser.
239
+ hits.push({ row: labelRow, col0: 0, col1: cols - 1, kind: 'regRow', index: i });
222
240
  for (const line of wrapText(r.description, Math.max(8, cols - 6))) {
223
241
  out.push(C.gray + ' ' + line + C.reset);
224
242
  }
package/src/remote.js CHANGED
@@ -23,6 +23,24 @@ import os from 'node:os';
23
23
  import path from 'node:path';
24
24
  import { skillsDir, normalizeSkillName } from './skills.js';
25
25
 
26
+ // TLS verification is ON by default. If a local proxy / AV breaks the cert
27
+ // chain, set HNCODE_DISABLE_TLS_VERIFY to skip verification for GitHub fetches.
28
+ if (process.env.HNCODE_DISABLE_TLS_VERIFY) {
29
+ // Node prints an "insecure TLS" warning for this intentional opt-out. Replace the
30
+ // default `warning` handler with one that swallows only that message, so the rest
31
+ // of Node's warnings still come through.
32
+ process.removeAllListeners('warning');
33
+ process.on('warning', (warning) => {
34
+ if (warning && /NODE_TLS_REJECT_UNAUTHORIZED/i.test(warning.message || '')) return;
35
+ const pid = process.pid;
36
+ const name = warning && warning.name ? `${warning.name}: ` : '';
37
+ let line = `(node:${pid}) ${name}${warning && warning.message ? warning.message : String(warning)}`;
38
+ if (warning && warning.detail) line += `\n${warning.detail}`;
39
+ process.stderr.write(line + '\n');
40
+ });
41
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
42
+ }
43
+
26
44
  // The canonical source. Overridable so a fork (or a test) can point elsewhere.
27
45
  export const DEFAULT_REPO = process.env.HNCODE_REPO || 'NiceHello666/hncode';
28
46
  export const DEFAULT_REF = process.env.HNCODE_REPO_REF || 'main';
@@ -75,11 +93,11 @@ async function fetchText(url, { timeoutMs = 20000 } = {}) {
75
93
  }
76
94
 
77
95
  function apiContents(repo, ref, dir) {
78
- return `http://127.0.0.1:62578/api/repos/${repo}/contents/${dir}${ref ? `?ref=${encodeURIComponent(ref)}` : ''}`;
96
+ return `https://api.github.com/repos/${repo}/contents/${dir}${ref ? `?ref=${encodeURIComponent(ref)}` : ''}`;
79
97
  }
80
98
 
81
99
  function rawUrl(repo, ref, file) {
82
- return `http://127.0.0.1:62578/raw/${repo}/${ref}/${file}`;
100
+ return `https://raw.githubusercontent.com/${repo}/${ref}/${file}`;
83
101
  }
84
102
 
85
103
  // Each item is a DIRECTORY in the repo (skills/<name>/, plugins/<name>/). A loose file
package/src/rollout.js ADDED
@@ -0,0 +1,151 @@
1
+ // Session replay (/replay) — play a recorded conversation back, one step at a time.
2
+ //
3
+ // Why this exists: a session file records WHAT happened but not how long it took or
4
+ // in what ORDER relative to the tools, and reading the raw JSON to understand a run
5
+ // is unpleasant. More importantly, when something went wrong two hours ago, the
6
+ // question is "what did the model see when it made that bad call" — and the answer is
7
+ // the transcript AS IT STOOD at that step, which is not the same as the final
8
+ // transcript.
9
+ //
10
+ // So the replay is a step index over the session's messages, with the state at each
11
+ // step reconstructable. It is deliberately read-only: nothing here writes a file or
12
+ // resumes execution. A replay that could act would be a footgun.
13
+ //
14
+ // Steps are the unit the user cares about — a user message, an assistant reply, a
15
+ // tool call, a tool result — not raw JSON entries, because one assistant message can
16
+ // carry several tool calls and the interesting question is "which tool ran, in what
17
+ // order".
18
+
19
+ /** The kinds of step a replay can stop on, in the order they can occur. */
20
+ export const STEP_KINDS = ['user', 'assistant', 'thinking', 'tool', 'system'];
21
+
22
+ /**
23
+ * Turn a session's messages into replay steps.
24
+ *
25
+ * Returns an array of
26
+ * `{ index, kind, role, label, text, toolName, args, at }`. `at` is only present
27
+ * when the recording kept a timestamp, which older sessions do not.
28
+ */
29
+ export function buildSteps(messages) {
30
+ const list = Array.isArray(messages) ? messages : [];
31
+ const steps = [];
32
+ for (const m of list) {
33
+ if (!m || typeof m !== 'object') continue;
34
+ const role = String(m.role || '');
35
+ const at = m.at || m.timestamp || null;
36
+ if (role === 'assistant') {
37
+ // Split one assistant message into its reasoning, its prose, and each tool
38
+ // call: the tool calls are the steps a debugging reader wants on their own.
39
+ const thinking = typeof m.reasoning === 'string' ? m.reasoning : '';
40
+ if (thinking.trim()) {
41
+ steps.push({ kind: 'thinking', role, label: 'thinking', text: thinking, at });
42
+ }
43
+ const text = typeof m.content === 'string' ? m.content : '';
44
+ if (text.trim()) {
45
+ steps.push({ kind: 'assistant', role, label: 'reply', text, at });
46
+ }
47
+ const calls = Array.isArray(m.toolCalls) ? m.toolCalls : [];
48
+ for (const c of calls) {
49
+ const name = (c && (c.name || (c.function && c.function.name))) || 'tool';
50
+ steps.push({
51
+ kind: 'tool', role, label: name, toolName: name,
52
+ text: '', args: c && (c.args || c.arguments || (c.function && c.function.arguments)) || null,
53
+ at,
54
+ });
55
+ }
56
+ if (!thinking.trim() && !text.trim() && !calls.length) {
57
+ steps.push({ kind: 'assistant', role, label: 'reply', text: '', at });
58
+ }
59
+ continue;
60
+ }
61
+ if (role === 'tool') {
62
+ const name = m.toolName || m.name || 'tool';
63
+ steps.push({ kind: 'tool', role, label: `${name} result`, toolName: name, text: typeof m.content === 'string' ? m.content : '', at });
64
+ continue;
65
+ }
66
+ if (role === 'user' || role === 'system') {
67
+ steps.push({ kind: role, role, label: role === 'user' ? 'prompt' : 'system', text: typeof m.content === 'string' ? m.content : '', at });
68
+ continue;
69
+ }
70
+ // Anything else (a plugin-injected role, a future one) is shown as itself rather
71
+ // than dropped: a replay that silently omits steps is worse than an ugly label.
72
+ steps.push({ kind: 'other', role, label: role || 'entry', text: typeof m.content === 'string' ? m.content : '', at });
73
+ }
74
+ return steps.map((s, i) => ({ ...s, index: i }));
75
+ }
76
+
77
+ /**
78
+ * The state at step `i`: which prompt is being answered and what has been seen.
79
+ *
80
+ * This is the "as it stood" view — the whole reason to replay rather than scroll.
81
+ */
82
+ export function stateAtStep(steps, i) {
83
+ const list = Array.isArray(steps) ? steps : [];
84
+ const at = Math.max(0, Math.min(list.length - 1, i | 0));
85
+ let lastPrompt = '';
86
+ let tools = 0;
87
+ for (let n = 0; n <= at; n++) {
88
+ const s = list[n];
89
+ if (!s) continue;
90
+ if (s.kind === 'user') lastPrompt = s.text;
91
+ if (s.kind === 'tool') tools++;
92
+ }
93
+ return {
94
+ index: at,
95
+ lastPrompt,
96
+ tools,
97
+ // The turn number the step belongs to, counting prompts: 1-based for display.
98
+ turn: list.slice(0, at + 1).filter((s) => s && s.kind === 'user').length || 1,
99
+ };
100
+ }
101
+
102
+ /** A one-line summary per step, for the scrubber list. */
103
+ export function stepLine(step, width = 72) {
104
+ if (!step) return '';
105
+ const t = String(step.text || '').replace(/\s+/g, ' ').trim();
106
+ const arg = step.args == null ? ''
107
+ : (typeof step.args === 'string' ? step.args : JSON.stringify(step.args)).replace(/\s+/g, ' ');
108
+ const body = t || arg;
109
+ const cut = body.length > width ? body.slice(0, width - 1) + '…' : body;
110
+ return cut;
111
+ }
112
+
113
+ /**
114
+ * The frame for one step: a header plus the step's body, wrapped.
115
+ *
116
+ * Wrapping is done here rather than by the caller so a replay view and a test agree
117
+ * on the exact rows.
118
+ */
119
+ export function renderStep(steps, i, opts = {}) {
120
+ const list = Array.isArray(steps) ? steps : [];
121
+ if (!list.length) return ['(this session has no steps to replay)'];
122
+ const at = Math.max(0, Math.min(list.length - 1, i | 0));
123
+ const s = list[at];
124
+ const st = stateAtStep(list, at);
125
+ const width = Math.max(20, opts.width || 76);
126
+ const out = [];
127
+ out.push(`step ${at + 1}/${list.length} turn ${st.turn} ${s.kind}${s.toolName ? ' ' + s.toolName : ''}`);
128
+ if (st.lastPrompt) out.push(` answering: ${st.lastPrompt.split('\n')[0].slice(0, width - 12)}`);
129
+ if (s.at) out.push(` at: ${s.at}`);
130
+ out.push('');
131
+ const body = [String(s.text || ''), s.args == null ? '' : (typeof s.args === 'string' ? s.args : JSON.stringify(s.args, null, 2))]
132
+ .filter((x) => x && String(x).trim()).join('\n');
133
+ const rows = body ? String(body).split('\n') : ['(no content)'];
134
+ for (const r of rows) {
135
+ if (r.length <= width) { out.push(r); continue; }
136
+ for (let k = 0; k < r.length; k += width) out.push(r.slice(k, k + width));
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /** Totals for the header: how many steps of each kind, and how many tools ran. */
142
+ export function replaySummary(steps) {
143
+ const list = Array.isArray(steps) ? steps : [];
144
+ const counts = {};
145
+ for (const s of list) counts[s.kind] = (counts[s.kind] || 0) + 1;
146
+ const calls = list.filter((s) => s.kind === 'tool' && s.role === 'assistant').length;
147
+ const results = list.filter((s) => s.kind === 'tool' && s.role === 'tool').length;
148
+ return { total: list.length, counts, calls, results };
149
+ }
150
+
151
+ export default { buildSteps, stateAtStep, stepLine, renderStep, replaySummary, STEP_KINDS };
@@ -0,0 +1,143 @@
1
+ // A shared, bounded cache of rendered transcript rows.
2
+ //
3
+ // WHY THIS EXISTS
4
+ // ---------------
5
+ // Wrapping and markdown-rendering a message is the expensive half of every frame, and the
6
+ // result depends only on (message text, width, colours) — so it is cacheable. The TUI
7
+ // already cached it, but on the message object itself, which makes the cache a property of
8
+ // the TRANSCRIPT rather than of the renderer:
9
+ //
10
+ // * It was dropped by position, not by size. `CACHE_KEEP` counted messages from the
11
+ // tail, so a 4000-message session threw away the caches of the 3700 oldest messages
12
+ // while keeping the caches of 300 tiny ones — the window tracked message COUNT, not
13
+ // memory. Scrolling back into a long session re-rendered from scratch every time.
14
+ // * It could not survive a session switch, because it lived on messages that
15
+ // `reconstructChat` throws away and rebuilds from the log. Every `--continue` and
16
+ // every `/sessions` resume started with a cold cache, re-wrapping the whole
17
+ // transcript before the first frame.
18
+ // * It had two independent mechanisms — `m._cache` for the row list, `m._mdCache` for
19
+ // the markdown blocks — with separate lifetimes and no bound between them.
20
+ //
21
+ // A `Map` keyed by a string, with entries ordered by use, fixes all three: the bound is
22
+ // real, the key survives any rebuild of the transcript, and both caches share one budget.
23
+ //
24
+ // THE KEY IS THE WHOLE POINT
25
+ // --------------------------
26
+ // A key that omitted a component of the layout would serve a row rendered for a different
27
+ // width or colour — which is not a stale cache, it is a MISDRAWN frame. So the key spells
28
+ // out everything the render depends on, and `makeRowKey` is the single place that decides
29
+ // what "the same" means. `evictIfStale` is the second half of the same contract: a streamed
30
+ // message's text grows every frame, so an entry whose text no longer matches is not a
31
+ // reuse, and the cache has to notice rather than serve it.
32
+
33
+ /** How many messages keep their rows. Chosen by measurement, not by feel: the rows for a
34
+ * typical assistant message are a few hundred bytes each, so 1500 covers a long session's
35
+ * visible history while staying in the low megabytes. */
36
+ export const ROW_CACHE_LIMIT = 1500;
37
+
38
+ /**
39
+ * A cache key that identifies a rendered message.
40
+ *
41
+ * Every input the render reads goes in, so "the same key" means "the same rows":
42
+ * `id` a stable identity for the message. Index-based ids break the moment a message
43
+ * is inserted or removed, which is exactly what compaction and /undo do.
44
+ * `text` the content. Compared by VALUE, not by reference: a streamed message's
45
+ * `text` is replaced on every delta, and a reference check would miss every
46
+ * reuse after the first frame.
47
+ * `w` the wrap width, `fg`/`codeFg` the colours, `expanded` the plugin-log toggle,
48
+ * `raw` the raw-mode flag, `pending`/`failed` the live-state flags, and `spin` the
49
+ * spinner key.
50
+ */
51
+ export function makeRowKey(parts) {
52
+ return [
53
+ parts.id,
54
+ parts.text == null ? '' : parts.text,
55
+ parts.w,
56
+ parts.fg,
57
+ parts.codeFg,
58
+ parts.expanded ? 1 : 0,
59
+ parts.raw ? 1 : 0,
60
+ parts.pending ? 1 : 0,
61
+ parts.failed ? 1 : 0,
62
+ parts.spin,
63
+ ].join('\x1f');
64
+ }
65
+
66
+ /**
67
+ * The shared store.
68
+ *
69
+ * A single `Map` with insertion order: `Map` iterates in insertion order and `set` on an
70
+ * existing key does NOT move it, so "touch on every read" has to be an explicit
71
+ * delete-then-set. That is what makes it an LRU rather than a FIFO — and the difference
72
+ * matters here, because the entries worth keeping are the ones being read, not the ones
73
+ * written most recently.
74
+ */
75
+ const store = new Map();
76
+
77
+ /** Hits and misses, so a caller can tell "the cache is not helping" from "there is
78
+ * nothing to cache" without instrumenting the renderer. */
79
+ const stats = { hits: 0, misses: 0, evictions: 0 };
80
+
81
+ export function cacheStats() {
82
+ return { ...stats, size: store.size };
83
+ }
84
+
85
+ /** For tests: a clean slate. */
86
+ export function resetRowCache() {
87
+ store.clear();
88
+ stats.hits = 0;
89
+ stats.misses = 0;
90
+ stats.evictions = 0;
91
+ }
92
+
93
+ /**
94
+ * Look up `key`, marking it as recently used.
95
+ *
96
+ * @returns the cached value, or undefined on a miss. Callers treat `undefined` as "render
97
+ * it" — a cache that cannot say "no" is not a cache.
98
+ */
99
+ export function rowCacheGet(key) {
100
+ if (!store.has(key)) { stats.misses++; return undefined; }
101
+ const v = store.get(key);
102
+ // Touch: re-insert so this key moves to the young end of the Map's iteration order.
103
+ store.delete(key);
104
+ store.set(key, v);
105
+ stats.hits++;
106
+ return v;
107
+ }
108
+
109
+ /**
110
+ * Store `value` under `key` and enforce the bound.
111
+ *
112
+ * Eviction is oldest-first, and it is done HERE rather than by the caller counting
113
+ * messages: the caller cannot know what the entries cost, and a message-count window
114
+ * keeps a lot of large messages while discarding a lot of small ones.
115
+ */
116
+ export function rowCacheSet(key, value) {
117
+ // Re-inserting an existing key would leave the old entry in place, so drop it first —
118
+ // otherwise the bound could be evictions short of what it looks like.
119
+ store.delete(key);
120
+ store.set(key, value);
121
+ while (store.size > ROW_CACHE_LIMIT) {
122
+ // Map iteration is insertion order, so the first key is the least recently used.
123
+ const oldest = store.keys().next();
124
+ if (oldest.done) break;
125
+ store.delete(oldest.value);
126
+ stats.evictions++;
127
+ }
128
+ return value;
129
+ }
130
+
131
+ /**
132
+ * Is `cached` still valid for `key`'s message?
133
+ *
134
+ * A separate question from `rowCacheGet`, because a message can change without its key
135
+ * changing: a streamed reply's text is the key's own `text` component, so its key DOES
136
+ * change — but the row COUNT memo does not carry the text, and that one has to be asked
137
+ * whether the content moved. Kept as a function so the check is named at the call site
138
+ * rather than being an open-coded `===` someone later widens by accident.
139
+ */
140
+ export function evictIfStale(entry, key) {
141
+ if (!entry) return true;
142
+ return entry.key !== key;
143
+ }