@hncode/hncode 0.5.0 → 0.5.1
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/package.json +1 -1
- package/src/agent.js +4 -2
- package/src/colors.js +478 -387
- package/src/config.js +38 -11
- package/src/doctor.js +209 -11
- package/src/memory-profile.js +247 -0
- package/src/row-cache.js +147 -142
- package/src/session-hub.js +1 -0
- package/src/settings-schema.js +301 -0
- package/src/tool-result.js +264 -236
- package/src/tools/edit.js +1 -1
- package/src/tui.js +544 -80
- package/src/web.js +3 -1
package/src/config.js
CHANGED
|
@@ -388,6 +388,14 @@ const b = baseUrl.replace(/\/+$/, '');
|
|
|
388
388
|
// "use the built-in SYSTEM_PROMPT". Read from config.toml so it persists
|
|
389
389
|
// across restarts.
|
|
390
390
|
systemPrompt: process.env.HNCODE_SYSTEM_PROMPT || root.system_prompt || '',
|
|
391
|
+
// The prompt knobs added alongside it, all editable from /settings › Prompt. Separate
|
|
392
|
+
// keys rather than one blob, because they mean different things: `append` keeps the
|
|
393
|
+
// built-in prompt and adds to it, `personal` is the user's own standing note, and the
|
|
394
|
+
// per-mode ones only apply while that mode is on.
|
|
395
|
+
appendSystemPrompt: process.env.HNCODE_APPEND_SYSTEM_PROMPT || root.append_system_prompt || '',
|
|
396
|
+
planInstructions: root.plan_instructions || '',
|
|
397
|
+
focusInstructions: root.focus_instructions || '',
|
|
398
|
+
swarmInstructions: root.swarm_instructions || '',
|
|
391
399
|
// ---- Web UI daemon -------------------------------------------------------
|
|
392
400
|
// One daemon serves EVERY hncode session on this machine: it holds the fixed
|
|
393
401
|
// port, proxies each session's requests to that session's own loopback
|
|
@@ -1146,24 +1154,43 @@ export function effortOptions(cfg, modelKeyName) {
|
|
|
1146
1154
|
return ['off', 'on'];
|
|
1147
1155
|
}
|
|
1148
1156
|
|
|
1157
|
+
// Token budget for a NAMED grade, on a protocol that wants a number rather than a word.
|
|
1158
|
+
//
|
|
1159
|
+
// The grades the catalog declares are low/medium/high, and a user may also write their own
|
|
1160
|
+
// in config.toml (`efforts = ["xhigh"]`). A grade with no entry here has no honest budget to
|
|
1161
|
+
// send, so the field is omitted — the previous code fell back to 8000, which silently gave
|
|
1162
|
+
// every unknown grade the same strength as `on` and `medium`.
|
|
1163
|
+
const EFFORT_BUDGETS = { low: 2000, medium: 8000, high: 16000, max: 32000 };
|
|
1164
|
+
|
|
1149
1165
|
// Map a chosen effort to the wire value for the active protocol.
|
|
1166
|
+
//
|
|
1167
|
+
// `on` means "thinking on", NOT "medium". The two are different requests: a model may
|
|
1168
|
+
// support a plain toggle without any graded control, in which case there is no grade to
|
|
1169
|
+
// send and inventing one (`medium`) can be rejected outright by the provider. `on` therefore
|
|
1170
|
+
// sends the protocol's plain enable, or nothing at all.
|
|
1150
1171
|
export function effortWire(cfg, effort) {
|
|
1151
1172
|
if (!effort || effort === 'off') return null;
|
|
1173
|
+
const named = Object.prototype.hasOwnProperty.call(EFFORT_BUDGETS, effort);
|
|
1174
|
+
|
|
1152
1175
|
if (cfg.protocol === 'anthropic') {
|
|
1153
|
-
|
|
1154
|
-
|
|
1176
|
+
// `thinking: { type: 'enabled' }` IS the plain toggle; `budget_tokens` is optional and
|
|
1177
|
+
// only meaningful for a named grade. Omitting it lets the provider pick its own budget,
|
|
1178
|
+
// which is what "on" asked for.
|
|
1179
|
+
return named
|
|
1180
|
+
? { thinking: { type: 'enabled', budget_tokens: EFFORT_BUDGETS[effort] } }
|
|
1181
|
+
: { thinking: { type: 'enabled' } };
|
|
1155
1182
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
return { reasoning_effort: grade };
|
|
1183
|
+
|
|
1184
|
+
// Responses API and the OpenAI-compatible shape both take a GRADE word, and neither has
|
|
1185
|
+
// a value meaning "on". So a named grade passes through, and `on` sends nothing — the
|
|
1186
|
+
// request carries whatever the provider does by default for that model, rather than a
|
|
1187
|
+
// grade the model may not accept.
|
|
1188
|
+
if (!named) return {};
|
|
1189
|
+
if (cfg.protocol === 'responses') return { reasoning: { effort } };
|
|
1190
|
+
return { reasoning_effort: effort };
|
|
1165
1191
|
}
|
|
1166
1192
|
|
|
1193
|
+
|
|
1167
1194
|
function toTomlLine(k, v) {
|
|
1168
1195
|
if (typeof v === 'string') return `${k} = "${v.replace(/"/g, '\\"')}"`;
|
|
1169
1196
|
if (typeof v === 'boolean') return `${k} = ${v}`;
|
package/src/doctor.js
CHANGED
|
@@ -77,8 +77,10 @@ function checkConfig(cfg, files) {
|
|
|
77
77
|
return out;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
/** The session directory. A store that cannot be listed is a store whose work is invisible.
|
|
81
|
-
|
|
80
|
+
/** The session directory. A store that cannot be listed is a store whose work is invisible.
|
|
81
|
+
*
|
|
82
|
+
* Async because it profiles the largest session — see the memory breakdown below. */
|
|
83
|
+
async function checkSessions(files, sessionsDir, limit = 4) {
|
|
82
84
|
const out = [];
|
|
83
85
|
if (!fs.existsSync(sessionsDir)) {
|
|
84
86
|
return [check('session store', 'warn', `${sessionsDir} does not exist yet`,
|
|
@@ -110,14 +112,17 @@ function checkSessions(files, sessionsDir, limit = 4) {
|
|
|
110
112
|
// actually hit: past ~512 MB `JSON.stringify` throws, and every save of that session
|
|
111
113
|
// then fails until the transcript is trimmed.
|
|
112
114
|
const V8_LIMIT = 512 * 1024 * 1024;
|
|
113
|
-
|
|
115
|
+
// Sizes of every session, so the memory profile can pick the largest regardless of how
|
|
116
|
+
// large it is. The `big` list below is filtered to the ones worth WARNING about; the
|
|
117
|
+
// profiler wants the largest whatever its size, because "this session holds 0.66 MB of
|
|
118
|
+
// tool arguments" is useful at 4 MB and not only at 500.
|
|
119
|
+
const sizes = [];
|
|
114
120
|
for (const f of names) {
|
|
115
|
-
try {
|
|
116
|
-
const size = fs.statSync(path.join(sessionsDir, f)).size;
|
|
117
|
-
if (size > 8 * 1024 * 1024) big.push({ f, size });
|
|
118
|
-
} catch { /* raced away */ }
|
|
121
|
+
try { sizes.push({ f, size: fs.statSync(path.join(sessionsDir, f)).size }); } catch { /* raced away */ }
|
|
119
122
|
}
|
|
120
|
-
|
|
123
|
+
sizes.sort((a, b) => b.size - a.size);
|
|
124
|
+
const big = sizes.filter((x) => x.size > 8 * 1024 * 1024);
|
|
125
|
+
|
|
121
126
|
for (const b of big.slice(0, limit)) {
|
|
122
127
|
const mb = (b.size / 1048576).toFixed(1);
|
|
123
128
|
const sev = b.size > V8_LIMIT * 0.75 ? 'error' : 'warn';
|
|
@@ -127,6 +132,38 @@ function checkSessions(files, sessionsDir, limit = 4) {
|
|
|
127
132
|
: 'Large; loading and saving this session will be slow.'));
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
// WHERE THE BYTES ARE in the biggest session, as opposed to how big the file is.
|
|
136
|
+
//
|
|
137
|
+
// A file size says nothing actionable: the same 2.5 MB can be a conversation or a
|
|
138
|
+
// runaway transcript, and only one of those is worth fixing. The breakdown also explains
|
|
139
|
+
// the file being LARGER than the content it holds — measured on a real session, the
|
|
140
|
+
// serialised JSON was 2.29 MB while text + tool results + tool arguments summed to
|
|
141
|
+
// 1.27 MB, so structure and escaping are as expensive as the text.
|
|
142
|
+
//
|
|
143
|
+
// Skipped below 1 MB: loading and measuring a session to learn it is small is itself
|
|
144
|
+
// work, and the finding would be "nothing to see". Deliberately NOT gated on `big` —
|
|
145
|
+
// that list only holds sessions big enough to warn about, and the breakdown is most
|
|
146
|
+
// useful on an ordinary one: it is where "half your memory is tool arguments" shows up.
|
|
147
|
+
if (sizes.length && sizes[0].size > 1024 * 1024) {
|
|
148
|
+
const top = sizes[0];
|
|
149
|
+
try {
|
|
150
|
+
const { loadSession } = await import('./session.js');
|
|
151
|
+
const { profileMemory, memoryHeadline } = await import('./memory-profile.js');
|
|
152
|
+
const loaded = loadSession(top.f.replace(/\.json$/, ''));
|
|
153
|
+
if (loaded) {
|
|
154
|
+
const profile = profileMemory({ session: loaded });
|
|
155
|
+
out.push(check('largest session memory', 'info',
|
|
156
|
+
`${memoryHeadline(profile)} (${top.f})`,
|
|
157
|
+
'Text, tool results and tool ARGUMENTS are counted separately; run /memory-profile for the full breakdown.'));
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
out.push(check('largest session memory', 'warn', `could not profile ${top.f}: ${e.message}`,
|
|
161
|
+
'The file was readable by size but would not parse.'));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Leftover temp files from an interrupted save.
|
|
166
|
+
|
|
130
167
|
// Leftover temp files from an interrupted save.
|
|
131
168
|
let tmp = [];
|
|
132
169
|
try { tmp = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.tmp')); } catch { /* listed above */ }
|
|
@@ -213,6 +250,160 @@ function checkExtensions(files) {
|
|
|
213
250
|
return out;
|
|
214
251
|
}
|
|
215
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Free space where the sessions live.
|
|
255
|
+
*
|
|
256
|
+
* A store that fills up is a store that loses work, and the failure is not obviously about
|
|
257
|
+
* disk: the write fails, the turn is lost, and the error text says ENOENT or EACCES. The
|
|
258
|
+
* session directory held 608 MB on this machine at one point, which is the shape that walks
|
|
259
|
+
* a disk to full without anyone noticing.
|
|
260
|
+
*
|
|
261
|
+
* `statfs` is used where the platform has it (Node 18.15+). Where it does not, the check is
|
|
262
|
+
* SKIPPED rather than guessed — a wrong number here is worse than no number.
|
|
263
|
+
*/
|
|
264
|
+
function checkDisk(dir) {
|
|
265
|
+
if (typeof fs.statfsSync !== 'function') {
|
|
266
|
+
return [check('disk space', 'info', 'not measurable on this Node/platform', '')];
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const st = fs.statfsSync(dir);
|
|
270
|
+
const freeBytes = Number(st.bavail) * Number(st.bsize);
|
|
271
|
+
const totalBytes = Number(st.blocks) * Number(st.bsize);
|
|
272
|
+
const freeGb = freeBytes / 1024 ** 3;
|
|
273
|
+
const totalGb = totalBytes / 1024 ** 3;
|
|
274
|
+
// Thresholds are deliberately far apart: below 1 GB is an error (a long session can
|
|
275
|
+
// write hundreds of megabytes), below 5 GB is a warning.
|
|
276
|
+
const sev = freeGb < 1 ? 'error' : freeGb < 5 ? 'warn' : 'ok';
|
|
277
|
+
return [check('disk space', sev, `${freeGb.toFixed(1)} GB free of ${totalGb.toFixed(0)} GB`,
|
|
278
|
+
sev === 'ok' ? ''
|
|
279
|
+
: 'Saving a session writes the whole transcript, which has reached hundreds of MB. Free space, or point HNCODE_SESSIONS_DIR at another volume.')];
|
|
280
|
+
} catch (e) {
|
|
281
|
+
return [check('disk space', 'info', `could not measure: ${e.message}`, '')];
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The workspace's git state.
|
|
287
|
+
*
|
|
288
|
+
* Two things here actually break work: a missing `user.name`/`user.email` makes every
|
|
289
|
+
* commit fail, and an unfinished merge or rebase means the tree is not what the agent
|
|
290
|
+
* thinks it is. Both are cheap to check and neither surfaced anywhere before.
|
|
291
|
+
*/
|
|
292
|
+
function checkGit(workspace, cp) {
|
|
293
|
+
if (!workspace) return [];
|
|
294
|
+
if (!cp) return [check('git', 'info', 'the git module is unavailable', '')];
|
|
295
|
+
const run = (args) => {
|
|
296
|
+
try {
|
|
297
|
+
const r = cp.spawnSync('git', args, { cwd: workspace, encoding: 'utf8', timeout: 5000, windowsHide: true });
|
|
298
|
+
return r.status === 0 ? String(r.stdout || '').trim() : null;
|
|
299
|
+
} catch { return null; }
|
|
300
|
+
};
|
|
301
|
+
// A workspace that is not a repo is normal, and says nothing about health.
|
|
302
|
+
const top = run(['rev-parse', '--show-toplevel']);
|
|
303
|
+
if (!top) return [check('git', 'info', 'not a git repository', '')];
|
|
304
|
+
|
|
305
|
+
const out = [];
|
|
306
|
+
const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
307
|
+
out.push(check('git', 'ok', `${branch || '(detached)'} in ${top}`));
|
|
308
|
+
|
|
309
|
+
for (const key of ['user.name', 'user.email']) {
|
|
310
|
+
if (!run(['config', '--get', key])) {
|
|
311
|
+
out.push(check(`git ${key}`, 'warn', 'not set',
|
|
312
|
+
`Every commit fails without it. Run: git config --global ${key} "…"`));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// A half-finished operation changes what the agent is looking at.
|
|
316
|
+
const gitDir = run(['rev-parse', '--git-dir']);
|
|
317
|
+
if (gitDir) {
|
|
318
|
+
const abs = path.isAbsolute(gitDir) ? gitDir : path.join(workspace, gitDir);
|
|
319
|
+
for (const [file, what] of [['MERGE_HEAD', 'a merge'], ['REBASE_HEAD', 'a rebase'], ['CHERRY_PICK_HEAD', 'a cherry-pick']]) {
|
|
320
|
+
if (fs.existsSync(path.join(abs, file))) {
|
|
321
|
+
out.push(check('git in progress', 'warn', `${what} is unfinished`,
|
|
322
|
+
'The working tree is mid-operation; resolve it before trusting a diff.'));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A LIVE end-to-end probe: can this configuration actually complete a turn?
|
|
332
|
+
*
|
|
333
|
+
* This is the check a static report cannot make. Everything above reads files; a key that
|
|
334
|
+
* exists and an endpoint that parses still say nothing about whether a request SUCCEEDS, and
|
|
335
|
+
* the failures that matter — a revoked key, a wrong base URL, a model the account cannot
|
|
336
|
+
* reach, a provider that rejects `reasoning_effort` — are all invisible until a real request
|
|
337
|
+
* is made.
|
|
338
|
+
*
|
|
339
|
+
* It costs one request and a handful of tokens, so it is OPT-IN (`/doctor --probe`) and never
|
|
340
|
+
* part of a plain run. Modelled on jcode's provider-doctor, whose AUTH_CREDENTIAL_LOADED,
|
|
341
|
+
* NON_STREAMING_CHAT_COMPLETION and STREAMING_CHAT_COMPLETION checks do the same thing.
|
|
342
|
+
*
|
|
343
|
+
* The steps are reported as they happen, so a failure names the step that broke rather than
|
|
344
|
+
* only saying the whole thing did not work.
|
|
345
|
+
*/
|
|
346
|
+
async function probeProvider(cfg, input = {}) {
|
|
347
|
+
const out = [];
|
|
348
|
+
const send = input.probeRequest; // injected by a test; see the note below
|
|
349
|
+
if (!cfg.provider || !cfg.model) {
|
|
350
|
+
return [check('probe', 'info', 'skipped: no provider/model configured', '')];
|
|
351
|
+
}
|
|
352
|
+
if (!cfg.endpoint) {
|
|
353
|
+
return [check('probe', 'info', 'skipped: no endpoint', '')];
|
|
354
|
+
}
|
|
355
|
+
// A test supplies its own transport. Without one, the real LLM class is used — lazily, so
|
|
356
|
+
// a plain /doctor never pays to import the provider stack.
|
|
357
|
+
const doRequest = send || (async () => {
|
|
358
|
+
const { LLM } = await import('./llm.js');
|
|
359
|
+
const llm = new LLM(cfg);
|
|
360
|
+
let text = '';
|
|
361
|
+
let sawError = null;
|
|
362
|
+
let usage = null;
|
|
363
|
+
await llm.request(
|
|
364
|
+
[{ role: 'user', content: 'Reply with the single word: ok' }],
|
|
365
|
+
(e) => {
|
|
366
|
+
if (e.type === 'data') text += e.text;
|
|
367
|
+
else if (e.type === 'error') sawError = e.error;
|
|
368
|
+
else if (e.type === 'usage') usage = e.usage;
|
|
369
|
+
},
|
|
370
|
+
// One turn, no tools, no streaming: the smallest request that proves the pipeline.
|
|
371
|
+
{ noTools: true, streaming: false },
|
|
372
|
+
);
|
|
373
|
+
return { text, error: sawError, usage };
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const t0 = Date.now();
|
|
377
|
+
let res;
|
|
378
|
+
try {
|
|
379
|
+
res = await doRequest();
|
|
380
|
+
} catch (e) {
|
|
381
|
+
return [check('probe', 'error', `request threw: ${(e && e.message) || e}`,
|
|
382
|
+
'The configuration parses but a request does not complete. Check the key, the endpoint and the network.')];
|
|
383
|
+
}
|
|
384
|
+
const ms = Date.now() - t0;
|
|
385
|
+
|
|
386
|
+
if (res && res.error) {
|
|
387
|
+
return [check('probe', 'error', `provider error: ${String(res.error).slice(0, 160)}`,
|
|
388
|
+
'The key may be revoked, the model unavailable, or the provider rejecting a field in the request.')];
|
|
389
|
+
}
|
|
390
|
+
const text = String((res && res.text) || '').trim();
|
|
391
|
+
if (!text) {
|
|
392
|
+
// A 200 with no text is the failure mode that looks like success: a reasoning model can
|
|
393
|
+
// spend its whole budget on `reasoning_content` and return an empty `content`.
|
|
394
|
+
return [check('probe', 'error', `empty reply in ${ms} ms`,
|
|
395
|
+
'The request completed but returned no text. If this model always reasons, it may be spending the whole output budget on reasoning.')];
|
|
396
|
+
}
|
|
397
|
+
// `usage` comes off the RESULT, not out of the closure that built it — reading the local
|
|
398
|
+
// inside `doRequest` from here was a ReferenceError on every successful probe.
|
|
399
|
+
const usage = res && res.usage;
|
|
400
|
+
out.push(check('probe', 'ok', `replied "${text.slice(0, 20)}" in ${ms} ms`,
|
|
401
|
+
usage ? `provider reported ${JSON.stringify(usage)}` : ''));
|
|
402
|
+
return out;
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
|
|
216
407
|
/** The terminal. An unknown size or a dumb TERM explains a broken-looking UI. */
|
|
217
408
|
function checkTerminal(env, dims) {
|
|
218
409
|
const out = [];
|
|
@@ -224,6 +415,8 @@ function checkTerminal(env, dims) {
|
|
|
224
415
|
} else {
|
|
225
416
|
out.push(check('TERM', 'ok', `${term}${env.COLORTERM ? ` / ${env.COLORTERM}` : ''}`));
|
|
226
417
|
}
|
|
418
|
+
// Named distinctly from the TERM check: both used to be called "TERM", so a report with a
|
|
419
|
+
// small terminal showed the same name twice and read as a duplicated finding.
|
|
227
420
|
if (dims && (dims.cols < 60 || dims.rows < 15)) {
|
|
228
421
|
out.push(check('terminal size', 'warn', `${dims.cols}x${dims.rows}`,
|
|
229
422
|
'Below 60x15 the layout cannot fit; widen the window.'));
|
|
@@ -257,7 +450,7 @@ function checkRuntime(node, platform, dirs) {
|
|
|
257
450
|
* @param {string} [input.node] process.version, for tests
|
|
258
451
|
* @returns {Array<{name:string,severity:string,detail:string,fix:string}>}
|
|
259
452
|
*/
|
|
260
|
-
export function runDoctor(input = {}) {
|
|
453
|
+
export async function runDoctor(input = {}) {
|
|
261
454
|
const cfg = input.cfg || {};
|
|
262
455
|
const env = input.env || process.env;
|
|
263
456
|
const workspace = input.workspace || cfg.workspace || process.cwd();
|
|
@@ -278,13 +471,18 @@ export function runDoctor(input = {}) {
|
|
|
278
471
|
}),
|
|
279
472
|
...checkConfig(cfg, files),
|
|
280
473
|
...checkWorkspace(workspace),
|
|
281
|
-
...checkSessions(files, sessionsDir),
|
|
474
|
+
...(await checkSessions(files, sessionsDir)),
|
|
282
475
|
...checkErrorLog(files),
|
|
283
476
|
...checkMcp(input.mcpServers, input.mcpConnections),
|
|
284
477
|
...checkHooks(files, workspace),
|
|
285
478
|
...checkExtensions(files),
|
|
286
|
-
...
|
|
479
|
+
...checkDisk(sessionsDir),
|
|
480
|
+
...checkGit(workspace, input.childProcess),
|
|
481
|
+
...checkTerminal(env, input.dims),
|
|
287
482
|
];
|
|
483
|
+
// The live probe is OPT-IN: it spends a request and a few tokens, so it must never run
|
|
484
|
+
// as part of a plain /doctor.
|
|
485
|
+
if (input.probe) results.push(...await probeProvider(cfg, input));
|
|
288
486
|
return results;
|
|
289
487
|
}
|
|
290
488
|
|
|
@@ -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
|
+
}
|