@hncode/hncode 0.5.0 → 0.5.2
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/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +1 -1
- package/src/agent-task.js +12 -1
- package/src/agent.js +115 -35
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +42 -6
- package/src/colors.js +478 -387
- package/src/config.js +38 -11
- package/src/context-bar.js +46 -5
- package/src/doctor.js +214 -11
- package/src/git.js +35 -4
- package/src/llm.js +6 -0
- package/src/memory-profile.js +247 -0
- package/src/permissions.js +29 -7
- package/src/registry-browser.js +21 -6
- package/src/row-cache.js +147 -142
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-hub.js +1 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +304 -0
- package/src/shell-safety.js +95 -6
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +139 -0
- package/src/team/runner.js +347 -0
- package/src/team/spawn.js +247 -0
- package/src/team/store.js +145 -0
- package/src/team/tasks.js +164 -0
- package/src/team/team.js +311 -0
- package/src/team-panel.js +226 -0
- package/src/term.js +27 -0
- package/src/tool-result.js +279 -236
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +53 -3
- package/src/tools/edit.js +591 -436
- package/src/tools/git.js +15 -3
- package/src/tools/index.js +2 -0
- package/src/tools/team.js +398 -0
- package/src/tools/web-search.js +88 -75
- package/src/tui.js +6187 -4780
- package/src/update.js +285 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
- package/src/web.js +3 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Session memory profiler — where the bytes actually go.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// ---------------
|
|
5
|
+
// Two questions came up that nothing here could answer: "why is the process using so much
|
|
6
|
+
// memory", and "did the transcript-amplification fix actually save anything". Both are
|
|
7
|
+
// unanswerable without knowing WHICH content is large, because "the session" is not one
|
|
8
|
+
// thing — the same conversation exists in several forms at once:
|
|
9
|
+
//
|
|
10
|
+
// * `session.messages` what gets sent to the model (text + tool args + tool results)
|
|
11
|
+
// * `state.chat` what the renderer draws (a DIFFERENT array of rows)
|
|
12
|
+
// * `session.transcript` the display-only record that is written to disk
|
|
13
|
+
//
|
|
14
|
+
// and within `messages` the bytes are not where a count of rows suggests. Measured on a
|
|
15
|
+
// real 2.6 MB session: text was 0.28 MB, tool RESULTS 0.34 MB, and tool-call ARGUMENTS
|
|
16
|
+
// 0.66 MB — the arguments, which no row count hints at, were more than everything else
|
|
17
|
+
// combined. On that same session the serialised JSON was 2.29 MB while the sum of its
|
|
18
|
+
// parts was 1.27 MB, so roughly as much again is STRUCTURE: keys, role tags, escaped
|
|
19
|
+
// strings.
|
|
20
|
+
//
|
|
21
|
+
// Modelled on jcode's `jcode-base/src/session/memory_profile.rs`, which records the same
|
|
22
|
+
// split plus the extremes (largest single block), because a profile without a maximum
|
|
23
|
+
// hides the one item that actually caused the problem.
|
|
24
|
+
//
|
|
25
|
+
// Pure and read-only: it measures, it never trims, loads or rewrites anything.
|
|
26
|
+
|
|
27
|
+
/** Content larger than this is counted separately — a single 16 KB block is the shape of
|
|
28
|
+
* a runaway, while 16 KB spread evenly is nothing. */
|
|
29
|
+
const LARGE_BLOB_BYTES = 16 * 1024;
|
|
30
|
+
|
|
31
|
+
/** Byte size of a value as it would sit in memory: strings as-is, everything else as JSON. */
|
|
32
|
+
function bytes(v) {
|
|
33
|
+
if (v == null) return 0;
|
|
34
|
+
if (typeof v === 'string') return Buffer.byteLength(v, 'utf8');
|
|
35
|
+
if (typeof v === 'number' || typeof v === 'boolean') return 8;
|
|
36
|
+
try { return Buffer.byteLength(JSON.stringify(v), 'utf8'); } catch { return 0; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One accumulating bucket. Every measurement lands in exactly one category. */
|
|
40
|
+
function bucket() {
|
|
41
|
+
return { bytes: 0, count: 0, max: 0 };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function add(b, n) {
|
|
45
|
+
b.bytes += n;
|
|
46
|
+
b.count += 1;
|
|
47
|
+
if (n > b.max) b.max = n;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const mb = (n) => (n / 1048576).toFixed(2);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Profile the memory a session occupies, broken down by content type.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} input
|
|
56
|
+
* @param {object} [input.session] a loaded session ({ messages, transcript })
|
|
57
|
+
* @param {Array} [input.chat] `state.chat` — the renderer's rows, if present
|
|
58
|
+
* @param {object} [input.state] the live TUI state, to measure chat and caches
|
|
59
|
+
* @returns {object} a plain report; see `renderMemoryProfile` for the human form
|
|
60
|
+
*/
|
|
61
|
+
export function profileMemory(input = {}) {
|
|
62
|
+
const session = input.session || {};
|
|
63
|
+
const messages = Array.isArray(session.messages) ? session.messages : [];
|
|
64
|
+
const transcript = Array.isArray(session.transcript) ? session.transcript : [];
|
|
65
|
+
|
|
66
|
+
// ---- session.messages, split by what the bytes are --------------------
|
|
67
|
+
const text = bucket();
|
|
68
|
+
const toolResults = bucket();
|
|
69
|
+
const toolArgs = bucket();
|
|
70
|
+
const images = bucket();
|
|
71
|
+
const argsByTool = new Map();
|
|
72
|
+
|
|
73
|
+
for (const m of messages) {
|
|
74
|
+
if (!m || typeof m !== 'object') continue;
|
|
75
|
+
const isResult = m.role === 'tool';
|
|
76
|
+
add(isResult ? toolResults : text, bytes(m.content));
|
|
77
|
+
// An image rides as a data: URL or a base64 field rather than in `content`, so it is
|
|
78
|
+
// measured separately — base64 inflates 4/3, and that is worth knowing.
|
|
79
|
+
const img = m.image || m.images || m.media;
|
|
80
|
+
if (img) add(images, bytes(img));
|
|
81
|
+
for (const tc of Array.isArray(m.toolCalls) ? m.toolCalls : []) {
|
|
82
|
+
if (!tc || typeof tc !== 'object') continue;
|
|
83
|
+
const n = bytes(tc.args);
|
|
84
|
+
add(toolArgs, n);
|
|
85
|
+
const name = String(tc.name || '?');
|
|
86
|
+
if (!argsByTool.has(name)) argsByTool.set(name, bucket());
|
|
87
|
+
add(argsByTool.get(name), n);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- session.transcript, which is display-only and can dwarf everything
|
|
92
|
+
const transcriptByRole = new Map();
|
|
93
|
+
let transcriptBytes = 0;
|
|
94
|
+
let transcriptLarge = 0;
|
|
95
|
+
let transcriptMax = 0;
|
|
96
|
+
for (const e of transcript) {
|
|
97
|
+
const row = e && e.row;
|
|
98
|
+
if (!row) continue;
|
|
99
|
+
const n = bytes(row.text);
|
|
100
|
+
transcriptBytes += n;
|
|
101
|
+
if (n > transcriptMax) transcriptMax = n;
|
|
102
|
+
if (n >= LARGE_BLOB_BYTES) transcriptLarge += n;
|
|
103
|
+
const role = String(row.role || '?');
|
|
104
|
+
if (!transcriptByRole.has(role)) transcriptByRole.set(role, bucket());
|
|
105
|
+
add(transcriptByRole.get(role), n);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- the renderer's copy ----------------------------------------------
|
|
109
|
+
// Derived from `messages` when the caller does not supply the live `state.chat`, so a
|
|
110
|
+
// profile of a session loaded off disk still accounts for what the renderer would build.
|
|
111
|
+
// `convRowFor` maps ONE message to rows, so it has to be applied per message — handing it
|
|
112
|
+
// the array returns a single empty row and the copy silently measured as zero, which is
|
|
113
|
+
// exactly what the first run of this printed.
|
|
114
|
+
let chat = Array.isArray(input.chat) ? input.chat : null;
|
|
115
|
+
if (!chat && input.state && Array.isArray(input.state.chat)) chat = input.state.chat;
|
|
116
|
+
if (!chat && messages.length && typeof input.convRowFor === 'function') {
|
|
117
|
+
chat = [];
|
|
118
|
+
for (const m of messages) {
|
|
119
|
+
const rows = input.convRowFor(m);
|
|
120
|
+
if (Array.isArray(rows)) chat.push(...rows);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
chat = chat || [];
|
|
124
|
+
const chatBytes = chat.reduce((a, r) => a + bytes(r && r.text), 0);
|
|
125
|
+
|
|
126
|
+
// ---- what it costs as one JSON document -------------------------------
|
|
127
|
+
// Reported because it is usually the biggest single number and nobody expects it: the
|
|
128
|
+
// serialised form carries every key and escape, which no content sum shows.
|
|
129
|
+
let jsonBytes = 0;
|
|
130
|
+
try { jsonBytes = Buffer.byteLength(JSON.stringify({ messages, transcript }), 'utf8'); } catch { /* */ }
|
|
131
|
+
|
|
132
|
+
const contentBytes = text.bytes + toolResults.bytes + toolArgs.bytes + images.bytes;
|
|
133
|
+
const measured = contentBytes + transcriptBytes;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
messages: {
|
|
137
|
+
count: messages.length,
|
|
138
|
+
text,
|
|
139
|
+
toolResults,
|
|
140
|
+
toolArgs,
|
|
141
|
+
images,
|
|
142
|
+
argsByTool,
|
|
143
|
+
contentBytes,
|
|
144
|
+
// The gap between the parts and the document: keys, role tags, escapes, and the
|
|
145
|
+
// array/record overhead of 3000 messages each carrying a role and a shape.
|
|
146
|
+
structuralBytes: Math.max(0, jsonBytes - measured),
|
|
147
|
+
jsonBytes,
|
|
148
|
+
},
|
|
149
|
+
transcript: {
|
|
150
|
+
count: transcript.length,
|
|
151
|
+
bytes: transcriptBytes,
|
|
152
|
+
largeBytes: transcriptLarge,
|
|
153
|
+
maxRowBytes: transcriptMax,
|
|
154
|
+
byRole: transcriptByRole,
|
|
155
|
+
},
|
|
156
|
+
chat: { count: chat.length, bytes: chatBytes },
|
|
157
|
+
// The three forms coexist, so the process holds roughly all of them at once.
|
|
158
|
+
totalBytes: measured + chatBytes,
|
|
159
|
+
totals: { messages: contentBytes, transcript: transcriptBytes, chat: chatBytes },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The profile as lines for a panel or a tool result.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} p from profileMemory
|
|
167
|
+
* @param {object} [opts]
|
|
168
|
+
* @param {number} [opts.top] how many rows of the per-tool table to show
|
|
169
|
+
* @param {boolean} [opts.verbose] include the per-tool breakdown
|
|
170
|
+
*/
|
|
171
|
+
export function renderMemoryProfile(p, opts = {}) {
|
|
172
|
+
const lines = [];
|
|
173
|
+
const total = p.totalBytes || 0;
|
|
174
|
+
// The document size is reported in the same table, so it must NOT join the percentage
|
|
175
|
+
// base — it is a different measure of the same data (serialised vs in-memory), and
|
|
176
|
+
// including it produced totals over 100%.
|
|
177
|
+
const contentBase = (p.messages.contentBytes || 0) + (p.transcript.bytes || 0) + (p.chat.bytes || 0);
|
|
178
|
+
// Rounding to 2 places on a few HUNDRED bytes made "5 bytes / 5 bytes" print as
|
|
179
|
+
// 100.0% but a document row print 1280.0% — a percentage over 100 is never a real
|
|
180
|
+
// share, so it is clamped rather than trusted.
|
|
181
|
+
const pct = (n) => {
|
|
182
|
+
if (!contentBase || !n) return '';
|
|
183
|
+
const v = Math.min(100, (100 * n) / contentBase);
|
|
184
|
+
return ` ${v.toFixed(1)}%`.padStart(7);
|
|
185
|
+
};
|
|
186
|
+
const row = (label, b, n, share = true) => {
|
|
187
|
+
lines.push(` ${label.padEnd(18)} ${mb(b).padStart(9)} MB${n != null ? ` x${n}` : ''}${share ? pct(b) : ''}`);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
lines.push(`Memory profile — ${mb(total)} MB across the forms a session holds`);
|
|
191
|
+
lines.push('');
|
|
192
|
+
lines.push(' content (session.messages)');
|
|
193
|
+
row('text', p.messages.text.bytes, p.messages.text.count);
|
|
194
|
+
row('tool results', p.messages.toolResults.bytes, p.messages.toolResults.count);
|
|
195
|
+
row('tool arguments', p.messages.toolArgs.bytes, p.messages.toolArgs.count);
|
|
196
|
+
if (p.messages.images.bytes) row('images', p.messages.images.bytes, p.messages.images.count);
|
|
197
|
+
|
|
198
|
+
if (p.transcript.count) {
|
|
199
|
+
lines.push('');
|
|
200
|
+
lines.push(' transcript (display-only, on disk)');
|
|
201
|
+
row('all rows', p.transcript.bytes, p.transcript.count);
|
|
202
|
+
if (p.transcript.largeBytes) {
|
|
203
|
+
lines.push(` of which >=16 KB: ${mb(p.transcript.largeBytes).padStart(9)} MB`);
|
|
204
|
+
}
|
|
205
|
+
lines.push(` largest row: ${mb(p.transcript.maxRowBytes).padStart(9)} MB`);
|
|
206
|
+
if (opts.verbose) {
|
|
207
|
+
for (const [role, b] of [...p.transcript.byRole].sort((a, c) => c[1].bytes - a[1].bytes)) {
|
|
208
|
+
row(` ${role}`, b.bytes, b.count);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
lines.push('');
|
|
214
|
+
lines.push(' other copies held at the same time');
|
|
215
|
+
row('state.chat (render)', p.chat.bytes, p.chat.count);
|
|
216
|
+
|
|
217
|
+
lines.push('');
|
|
218
|
+
lines.push(' as one JSON document (a different measure of the same data)');
|
|
219
|
+
// Measured against the CONTENT, not the content-plus-content the base holds, so these
|
|
220
|
+
// two rows carry no share: they re-measure what the rows above already report.
|
|
221
|
+
row('serialised size', p.messages.jsonBytes, null, false);
|
|
222
|
+
row('structure + escapes', p.messages.structuralBytes, null, false);
|
|
223
|
+
if (p.messages.structuralBytes > p.messages.contentBytes) {
|
|
224
|
+
lines.push(' (structure is larger than the content — that is normal for many short messages)');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (opts.verbose && p.messages.argsByTool.size) {
|
|
228
|
+
lines.push('');
|
|
229
|
+
lines.push(' tool arguments by tool');
|
|
230
|
+
const rows = [...p.messages.argsByTool].sort((a, b) => b[1].bytes - a[1].bytes);
|
|
231
|
+
for (const [name, b] of (opts.top ? rows.slice(0, opts.top) : rows)) {
|
|
232
|
+
row(name, b.bytes, b.count);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return lines;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A short verdict, for a place that wants one line rather than a table.
|
|
240
|
+
* @returns {string}
|
|
241
|
+
*/
|
|
242
|
+
export function memoryHeadline(p) {
|
|
243
|
+
const total = mb(p.totalBytes);
|
|
244
|
+
const args = mb(p.messages.toolArgs.bytes);
|
|
245
|
+
const tr = p.transcript.count ? mb(p.transcript.bytes) : '0';
|
|
246
|
+
return `${total} MB total · text ${mb(p.messages.text.bytes)} · tool results ${mb(p.messages.toolResults.bytes)} · tool args ${args} · transcript ${tr}`;
|
|
247
|
+
}
|
package/src/permissions.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
// and loses the prompt for genuinely dangerous commands too. A rule set lets the
|
|
7
7
|
// routine work through while the rest keeps asking.
|
|
8
8
|
//
|
|
9
|
+
// A `Bash(...)` prefix rule is matched against every segment of the line, not the
|
|
10
|
+
// line as a whole, so allowing one command cannot smuggle in a second one.
|
|
11
|
+
import { splitSegments } from './shell-safety.js';
|
|
12
|
+
|
|
9
13
|
// Rule syntax (a subset of Claude Code's, chosen to stay readable):
|
|
10
14
|
// Bash — every Bash call
|
|
11
15
|
// Bash(npm run test) — that exact command
|
|
@@ -76,17 +80,35 @@ export function ruleMatches(parsed, toolName, args) {
|
|
|
76
80
|
const a = args || {};
|
|
77
81
|
// Bash: the rule names a command. A trailing `*` means "starts with"; otherwise the
|
|
78
82
|
// command must match exactly, so `Bash(rm -rf /)` cannot be widened by an argument.
|
|
83
|
+
//
|
|
84
|
+
// A prefix rule applies to EVERY segment, not just the line as a whole. Comparing
|
|
85
|
+
// the whole string made `Bash(npm test *)` allow `npm test && rm -rf /build`,
|
|
86
|
+
// because the line does start with the prefix — the second command was never
|
|
87
|
+
// looked at. `splitSegments` is the same splitter the destructive-command check
|
|
88
|
+
// uses, so `;`, `|`, `&`, `&&` and `||` all separate here too.
|
|
79
89
|
if (toolName === 'Bash') {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
// A rule compares WORDS, so runs of whitespace collapse before comparing. Shell words are
|
|
91
|
+
// separated by one or more blanks and the count means nothing, so `git status` and
|
|
92
|
+
// `git status` are the same command — but a raw string comparison treated them as different
|
|
93
|
+
// and silently skipped a rule the user had written, which is the "it still prompts even
|
|
94
|
+
// though I allowed it" report.
|
|
95
|
+
const squash = (s) => String(s).replace(/\s+/g, ' ').trim();
|
|
96
|
+
const cmd = squash(a.command || a.cmd || '');
|
|
97
|
+
const rule = squash(parsed.arg);
|
|
98
|
+
if (rule.endsWith('*')) {
|
|
99
|
+
const prefix = squash(rule.slice(0, -1));
|
|
100
|
+
const { segments } = splitSegments(cmd);
|
|
101
|
+
const parts = (segments.length ? segments : [cmd]).map(squash).filter(Boolean);
|
|
102
|
+
// Every segment must be covered. An empty command still falls back to the whole-string
|
|
103
|
+
// comparison rather than matching anything.
|
|
104
|
+
if (!parts.length) return cmd === prefix;
|
|
105
|
+
return parts.every((p) => p === prefix || p.startsWith(prefix + ' '));
|
|
84
106
|
}
|
|
85
|
-
return cmd ===
|
|
107
|
+
return cmd === rule;
|
|
86
108
|
}
|
|
87
109
|
|
|
88
|
-
// Path-taking tools: match any of the tool's path-ish arguments against the glob,
|
|
89
|
-
//
|
|
110
|
+
// Path-taking tools: match any of the tool's path-ish arguments against the glob, so
|
|
111
|
+
// `Read(./src/**)` works whether the model sent `path` or `file_path`.
|
|
90
112
|
const names = PATH_ARGS[toolName] || ['path', 'file_path', 'pattern'];
|
|
91
113
|
for (const n of names) {
|
|
92
114
|
const v = a[n];
|
package/src/registry-browser.js
CHANGED
|
@@ -126,15 +126,30 @@ export function rowsForTab(state, tabId) {
|
|
|
126
126
|
}));
|
|
127
127
|
}
|
|
128
128
|
if (tabId === 'ondisk') {
|
|
129
|
-
|
|
129
|
+
// Match on the DIRECTORY a plugin was loaded from, not on the name it reports.
|
|
130
|
+
//
|
|
131
|
+
// A plugin declares its own `name`, and it need not match the directory it sits in. Comparing
|
|
132
|
+
// names meant any plugin where the two differed showed "Need Restart" forever: the name it
|
|
133
|
+
// reported could never equal the directory it was found under, so restarting never helped.
|
|
134
|
+
// `computer-use/` declaring `hncode-cu` is the ordinary case, not an oddity.
|
|
135
|
+
//
|
|
136
|
+
// `loaded` records each plugin with an `id` of `<dirname>/<entry>` while `onDisk` reports only a
|
|
137
|
+
// bare `name` (the directory, or the filename for a single-file plugin). The first segment of
|
|
138
|
+
// the id is therefore the thing to compare, and it covers both layouts: a directory's id starts
|
|
139
|
+
// with its directory name, and a single file's id IS its filename.
|
|
140
|
+
const loadedDirs = new Set();
|
|
141
|
+
const loadedNames = new Set();
|
|
142
|
+
for (const p of d.loaded || []) {
|
|
143
|
+
if (p.id) loadedDirs.add(String(p.id).split('/')[0]);
|
|
144
|
+
if (p.name) loadedNames.add(String(p.name));
|
|
145
|
+
}
|
|
146
|
+
const isLoaded = (p) => loadedDirs.has(String(p.name)) || loadedNames.has(String(p.name));
|
|
130
147
|
return (d.onDisk || []).map((p) => ({
|
|
131
148
|
name: p.name,
|
|
132
149
|
label: p.name + (p.kind === 'dir' ? '/' : ''),
|
|
133
|
-
description:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
status: loadedNames.has(p.name) ? 'loaded' : 'Need Restart',
|
|
137
|
-
statusTone: loadedNames.has(p.name) ? 'ok' : 'warn',
|
|
150
|
+
description: isLoaded(p) ? 'loaded in this session' : 'not loaded — Need Restart',
|
|
151
|
+
status: isLoaded(p) ? 'loaded' : 'Need Restart',
|
|
152
|
+
statusTone: isLoaded(p) ? 'ok' : 'warn',
|
|
138
153
|
action: 'remove-plugin',
|
|
139
154
|
}));
|
|
140
155
|
}
|
package/src/row-cache.js
CHANGED
|
@@ -1,143 +1,148 @@
|
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
*
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
export function
|
|
87
|
-
store.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
stats.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
// The palette. Cached rows are finished strings with their escapes already applied, so
|
|
64
|
+
// a theme switch has to miss every entry or the old colours come back. Without this a
|
|
65
|
+
// switched theme repainted the chrome (composed per frame) and left the transcript's
|
|
66
|
+
// own text in the previous palette — white words sitting in a gruvbox.
|
|
67
|
+
parts.theme || '',
|
|
68
|
+
].join('\x1f');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The shared store.
|
|
73
|
+
*
|
|
74
|
+
* A single `Map` with insertion order: `Map` iterates in insertion order and `set` on an
|
|
75
|
+
* existing key does NOT move it, so "touch on every read" has to be an explicit
|
|
76
|
+
* delete-then-set. That is what makes it an LRU rather than a FIFO — and the difference
|
|
77
|
+
* matters here, because the entries worth keeping are the ones being read, not the ones
|
|
78
|
+
* written most recently.
|
|
79
|
+
*/
|
|
80
|
+
const store = new Map();
|
|
81
|
+
|
|
82
|
+
/** Hits and misses, so a caller can tell "the cache is not helping" from "there is
|
|
83
|
+
* nothing to cache" without instrumenting the renderer. */
|
|
84
|
+
const stats = { hits: 0, misses: 0, evictions: 0 };
|
|
85
|
+
|
|
86
|
+
export function cacheStats() {
|
|
87
|
+
return { ...stats, size: store.size };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** For tests: a clean slate. */
|
|
91
|
+
export function resetRowCache() {
|
|
92
|
+
store.clear();
|
|
93
|
+
stats.hits = 0;
|
|
94
|
+
stats.misses = 0;
|
|
95
|
+
stats.evictions = 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Look up `key`, marking it as recently used.
|
|
100
|
+
*
|
|
101
|
+
* @returns the cached value, or undefined on a miss. Callers treat `undefined` as "render
|
|
102
|
+
* it" — a cache that cannot say "no" is not a cache.
|
|
103
|
+
*/
|
|
104
|
+
export function rowCacheGet(key) {
|
|
105
|
+
if (!store.has(key)) { stats.misses++; return undefined; }
|
|
106
|
+
const v = store.get(key);
|
|
107
|
+
// Touch: re-insert so this key moves to the young end of the Map's iteration order.
|
|
108
|
+
store.delete(key);
|
|
109
|
+
store.set(key, v);
|
|
110
|
+
stats.hits++;
|
|
111
|
+
return v;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Store `value` under `key` and enforce the bound.
|
|
116
|
+
*
|
|
117
|
+
* Eviction is oldest-first, and it is done HERE rather than by the caller counting
|
|
118
|
+
* messages: the caller cannot know what the entries cost, and a message-count window
|
|
119
|
+
* keeps a lot of large messages while discarding a lot of small ones.
|
|
120
|
+
*/
|
|
121
|
+
export function rowCacheSet(key, value) {
|
|
122
|
+
// Re-inserting an existing key would leave the old entry in place, so drop it first —
|
|
123
|
+
// otherwise the bound could be evictions short of what it looks like.
|
|
124
|
+
store.delete(key);
|
|
125
|
+
store.set(key, value);
|
|
126
|
+
while (store.size > ROW_CACHE_LIMIT) {
|
|
127
|
+
// Map iteration is insertion order, so the first key is the least recently used.
|
|
128
|
+
const oldest = store.keys().next();
|
|
129
|
+
if (oldest.done) break;
|
|
130
|
+
store.delete(oldest.value);
|
|
131
|
+
stats.evictions++;
|
|
132
|
+
}
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Is `cached` still valid for `key`'s message?
|
|
138
|
+
*
|
|
139
|
+
* A separate question from `rowCacheGet`, because a message can change without its key
|
|
140
|
+
* changing: a streamed reply's text is the key's own `text` component, so its key DOES
|
|
141
|
+
* change — but the row COUNT memo does not carry the text, and that one has to be asked
|
|
142
|
+
* whether the content moved. Kept as a function so the check is named at the call site
|
|
143
|
+
* rather than being an open-coded `===` someone later widens by accident.
|
|
144
|
+
*/
|
|
145
|
+
export function evictIfStale(entry, key) {
|
|
146
|
+
if (!entry) return true;
|
|
147
|
+
return entry.key !== key;
|
|
143
148
|
}
|