@hncode/hncode 0.4.2 → 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/README.md +1 -15
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +149 -35
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +478 -242
- package/src/config.js +409 -40
- package/src/context-bar.js +237 -0
- package/src/doctor.js +518 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/memory-profile.js +247 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +611 -58
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +31 -13
- package/src/remote.js +20 -2
- package/src/rollout.js +151 -0
- package/src/row-cache.js +148 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session-hub.js +1 -0
- package/src/session.js +178 -8
- package/src/settings-schema.js +301 -0
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tool-result.js +264 -236
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +34 -6
- package/src/tui.js +5637 -746
- package/src/web-public/app.js +7 -0
- package/src/web.js +3 -1
package/src/lsp.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// LSP client: real diagnostics and symbol lookup from a language server.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS NOT AN MCP SERVER
|
|
4
|
+
// ----------------------------
|
|
5
|
+
// `src/mcp.js` already speaks JSON-RPC, and the temptation is to reuse it. But MCP frames
|
|
6
|
+
// messages as NEWLINE-DELIMITED JSON while LSP frames them with a `Content-Length` header —
|
|
7
|
+
// mcp.js says so itself ("MCP's stdio transport is line-delimited (NOT Content-Length
|
|
8
|
+
// framed — that is the LSP convention, and mixing them up is the usual way this breaks)").
|
|
9
|
+
// Sharing the transport would mean one of the two protocols being wrong. So the framing is
|
|
10
|
+
// implemented here, and only the framing: everything above it is plain JSON-RPC.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT IS FOR
|
|
13
|
+
// --------------
|
|
14
|
+
// A model editing code benefits from the same thing a human does: being told that the line
|
|
15
|
+
// it just wrote does not compile. This gives the agent that, without a build step and
|
|
16
|
+
// without guessing — the compiler's own answer, over the protocol its own tooling speaks.
|
|
17
|
+
//
|
|
18
|
+
// NO SERVER IS NOT AN ERROR
|
|
19
|
+
// -------------------------
|
|
20
|
+
// Most machines have no language server installed. That is the normal case, not a broken
|
|
21
|
+
// one: `findServer` returns null, callers report "no server for this file", and everything
|
|
22
|
+
// else in hncode works exactly as before. A missing server must never make a turn fail.
|
|
23
|
+
|
|
24
|
+
import cp from 'node:child_process';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
/** A server definition: how to start it and which files it handles. */
|
|
30
|
+
export const SERVERS = [
|
|
31
|
+
{
|
|
32
|
+
id: 'rust-analyzer',
|
|
33
|
+
command: 'rust-analyzer',
|
|
34
|
+
extensions: ['.rs'],
|
|
35
|
+
// rust-analyzer only speaks stdio when told to; it otherwise looks for a socket.
|
|
36
|
+
args: [],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'typescript',
|
|
40
|
+
command: 'typescript-language-server',
|
|
41
|
+
args: ['--stdio'],
|
|
42
|
+
extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'pyright',
|
|
46
|
+
command: 'pyright-langserver',
|
|
47
|
+
args: ['--stdio'],
|
|
48
|
+
extensions: ['.py', '.pyi'],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'gopls',
|
|
52
|
+
command: 'gopls',
|
|
53
|
+
extensions: ['.go'],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'clangd',
|
|
57
|
+
command: 'clangd',
|
|
58
|
+
extensions: ['.c', '.h', '.cc', '.cpp', '.hpp', '.cxx'],
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Is `command` runnable? Checked without spawning, so a probe costs nothing. */
|
|
63
|
+
export function commandExists(command) {
|
|
64
|
+
const dirs = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
65
|
+
const exts = process.platform === 'win32'
|
|
66
|
+
? String(process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';').filter(Boolean)
|
|
67
|
+
: [''];
|
|
68
|
+
for (const d of dirs) {
|
|
69
|
+
for (const ext of exts) {
|
|
70
|
+
try {
|
|
71
|
+
const p = path.join(d, command + ext);
|
|
72
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
73
|
+
return p;
|
|
74
|
+
} catch { /* keep looking */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The server that handles a file, or null.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} file
|
|
84
|
+
* @returns {{id:string, command:string, args:string[], bin:string}|null}
|
|
85
|
+
*/
|
|
86
|
+
export function findServer(file) {
|
|
87
|
+
const ext = path.extname(String(file || '')).toLowerCase();
|
|
88
|
+
if (!ext) return null;
|
|
89
|
+
for (const s of SERVERS) {
|
|
90
|
+
if (!s.extensions.includes(ext)) continue;
|
|
91
|
+
const bin = commandExists(s.command);
|
|
92
|
+
if (bin) return { ...s, bin };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every server that is actually installed here, for /doctor and the tool's error text. */
|
|
98
|
+
export function availableServers() {
|
|
99
|
+
return SERVERS.map((s) => ({ id: s.id, command: s.command, bin: commandExists(s.command) }))
|
|
100
|
+
.filter((s) => s.bin)
|
|
101
|
+
.map((s) => ({ id: s.id, command: s.command, bin: s.bin }));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- framing ---------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Encode one JSON-RPC message with an LSP `Content-Length` header.
|
|
108
|
+
*
|
|
109
|
+
* The length is BYTES, not characters. A message containing a non-ASCII identifier —
|
|
110
|
+
* a Rust symbol with a Greek letter, a comment in Chinese — would be cut short by a
|
|
111
|
+
* character count, and the server would then desynchronise on the next frame, which shows
|
|
112
|
+
* up as every subsequent request hanging.
|
|
113
|
+
*/
|
|
114
|
+
export function encodeMessage(obj) {
|
|
115
|
+
const json = JSON.stringify(obj);
|
|
116
|
+
const len = Buffer.byteLength(json, 'utf8');
|
|
117
|
+
return Buffer.concat([
|
|
118
|
+
Buffer.from(`Content-Length: ${len}\r\n\r\n`, 'ascii'),
|
|
119
|
+
Buffer.from(json, 'utf8'),
|
|
120
|
+
]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A streaming decoder for `Content-Length` frames.
|
|
125
|
+
*
|
|
126
|
+
* Chunk boundaries are arbitrary, so a frame can arrive split or several at once. State is
|
|
127
|
+
* kept between calls; `push` returns whatever complete messages are available NOW.
|
|
128
|
+
*/
|
|
129
|
+
export function createDecoder() {
|
|
130
|
+
let buf = Buffer.alloc(0);
|
|
131
|
+
return {
|
|
132
|
+
push(chunk) {
|
|
133
|
+
buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
|
|
134
|
+
const out = [];
|
|
135
|
+
for (;;) {
|
|
136
|
+
const sep = buf.indexOf('\r\n\r\n');
|
|
137
|
+
if (sep < 0) break;
|
|
138
|
+
const header = buf.slice(0, sep).toString('ascii');
|
|
139
|
+
const m = /Content-Length:\s*(\d+)/i.exec(header);
|
|
140
|
+
if (!m) {
|
|
141
|
+
// An unparseable header means the stream is lost: drop what we have rather than
|
|
142
|
+
// spinning on it forever.
|
|
143
|
+
buf = buf.slice(sep + 4);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const len = Number(m[1]);
|
|
147
|
+
const start = sep + 4;
|
|
148
|
+
if (buf.length < start + len) break; // the rest has not arrived yet
|
|
149
|
+
const body = buf.slice(start, start + len).toString('utf8');
|
|
150
|
+
// Advance past the EXACT byte length, which is why the header was byte-counted.
|
|
151
|
+
buf = buf.slice(start + len);
|
|
152
|
+
try { out.push(JSON.parse(body)); } catch { /* a malformed frame is skipped */ }
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
},
|
|
156
|
+
/** Bytes held back waiting for the rest of a frame. */
|
|
157
|
+
get pending() { return buf.length; },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- the client ------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/** How long to wait for a server to answer before giving up. A language server that has
|
|
164
|
+
* not indexed a project will not answer at all, and a hung request is worse than an
|
|
165
|
+
* honest "no answer". */
|
|
166
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
167
|
+
/** How long to let the server finish its handshake and initial analysis. */
|
|
168
|
+
const INIT_TIMEOUT_MS = 30_000;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Start a server and complete the LSP handshake.
|
|
172
|
+
*
|
|
173
|
+
* @returns {Promise<{ok:true, client:object} | {ok:false, error:string}>}
|
|
174
|
+
*/
|
|
175
|
+
export async function startClient(server, root, opts = {}) {
|
|
176
|
+
const log = opts.log || (() => {});
|
|
177
|
+
let child;
|
|
178
|
+
try {
|
|
179
|
+
child = cp.spawn(server.bin, server.args || [], {
|
|
180
|
+
cwd: root,
|
|
181
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
182
|
+
// A shell is NOT used: the argv is already resolved, and a shell would let a path
|
|
183
|
+
// with a space in it split into two arguments.
|
|
184
|
+
shell: false,
|
|
185
|
+
});
|
|
186
|
+
} catch (e) {
|
|
187
|
+
return { ok: false, error: `could not start ${server.command}: ${e.message}` };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const decoder = createDecoder();
|
|
191
|
+
const pending = new Map();
|
|
192
|
+
let nextId = 1;
|
|
193
|
+
let dead = null;
|
|
194
|
+
let stderrTail = '';
|
|
195
|
+
const notifications = [];
|
|
196
|
+
|
|
197
|
+
child.stdout.on('data', (chunk) => {
|
|
198
|
+
for (const msg of decoder.push(chunk)) {
|
|
199
|
+
if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) {
|
|
200
|
+
const waiter = pending.get(msg.id);
|
|
201
|
+
if (waiter) { pending.delete(msg.id); waiter(msg); }
|
|
202
|
+
} else if (msg.method) {
|
|
203
|
+
// Server-initiated notifications and requests (publishDiagnostics, and the
|
|
204
|
+
// registerCapability requests a server sends during init). Requests are answered
|
|
205
|
+
// with a null result so the server does not block waiting on us.
|
|
206
|
+
notifications.push(msg);
|
|
207
|
+
if (msg.id != null) {
|
|
208
|
+
try { child.stdin.write(encodeMessage({ jsonrpc: '2.0', id: msg.id, result: null })); }
|
|
209
|
+
catch { /* the pipe is already gone */ }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
child.stderr.on('data', (d) => {
|
|
215
|
+
// Keep only the tail: rust-analyzer is chatty and the useful error is the last line.
|
|
216
|
+
stderrTail = (stderrTail + d.toString()).slice(-2000);
|
|
217
|
+
});
|
|
218
|
+
const onExit = (code, signal) => {
|
|
219
|
+
dead = `the language server exited (${signal || code})${stderrTail ? `: ${stderrTail.trim().split('\n').slice(-1)[0]}` : ''}`;
|
|
220
|
+
for (const [, waiter] of pending) waiter({ error: { message: dead } });
|
|
221
|
+
pending.clear();
|
|
222
|
+
};
|
|
223
|
+
child.on('exit', onExit);
|
|
224
|
+
child.on('error', (e) => { dead = `the language server failed: ${e.message}`; });
|
|
225
|
+
|
|
226
|
+
function send(msg) {
|
|
227
|
+
if (dead) return Promise.reject(new Error(dead));
|
|
228
|
+
try { child.stdin.write(encodeMessage(msg)); }
|
|
229
|
+
catch (e) { return Promise.reject(e); }
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function request(method, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
234
|
+
if (dead) return Promise.reject(new Error(dead));
|
|
235
|
+
const id = nextId++;
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
const timer = setTimeout(() => {
|
|
238
|
+
pending.delete(id);
|
|
239
|
+
reject(new Error(`${method} timed out after ${Math.round(timeoutMs / 1000)}s`));
|
|
240
|
+
}, timeoutMs);
|
|
241
|
+
pending.set(id, (msg) => {
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
244
|
+
else resolve(msg.result);
|
|
245
|
+
});
|
|
246
|
+
try { child.stdin.write(encodeMessage({ jsonrpc: '2.0', id, method, params })); }
|
|
247
|
+
catch (e) { clearTimeout(timer); pending.delete(id); reject(e); }
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function notify(method, params) {
|
|
252
|
+
try { child.stdin.write(encodeMessage({ jsonrpc: '2.0', method, params })); }
|
|
253
|
+
catch { /* a dead pipe: the exit handler has already recorded why */ }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const rootUri = pathToFileURL(path.resolve(root)).href;
|
|
257
|
+
const capabilities = {
|
|
258
|
+
textDocument: {
|
|
259
|
+
synchronization: { dynamicRegistration: false, didSave: true },
|
|
260
|
+
publishDiagnostics: { relatedInformation: true },
|
|
261
|
+
hover: { contentFormat: ['plaintext', 'markdown'] },
|
|
262
|
+
definition: { linkSupport: false },
|
|
263
|
+
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
|
264
|
+
},
|
|
265
|
+
workspace: { workspaceFolders: true, configuration: true },
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
await request('initialize', {
|
|
270
|
+
processId: process.pid,
|
|
271
|
+
rootUri,
|
|
272
|
+
rootPath: path.resolve(root),
|
|
273
|
+
workspaceFolders: [{ uri: rootUri, name: path.basename(path.resolve(root)) }],
|
|
274
|
+
capabilities,
|
|
275
|
+
initializationOptions: opts.initializationOptions || {},
|
|
276
|
+
}, INIT_TIMEOUT_MS);
|
|
277
|
+
} catch (e) {
|
|
278
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
279
|
+
return { ok: false, error: `initialize failed: ${e.message}` };
|
|
280
|
+
}
|
|
281
|
+
notify('initialized', {});
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
client: {
|
|
286
|
+
root,
|
|
287
|
+
serverId: server.id,
|
|
288
|
+
request,
|
|
289
|
+
notify,
|
|
290
|
+
notifications,
|
|
291
|
+
stderrTail: () => stderrTail,
|
|
292
|
+
dead: () => dead,
|
|
293
|
+
async stop() {
|
|
294
|
+
try { await request('shutdown', null, 3000); } catch { /* shutting down anyway */ }
|
|
295
|
+
notify('exit', null);
|
|
296
|
+
setTimeout(() => { try { child.kill(); } catch { /* gone */ } }, 250);
|
|
297
|
+
},
|
|
298
|
+
_child: child,
|
|
299
|
+
_raw: { log },
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Turn a file path into the `file://` URI LSP expects. */
|
|
305
|
+
export function fileUri(file) {
|
|
306
|
+
return pathToFileURL(path.resolve(file)).href;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** And back, for the locations a server reports. */
|
|
310
|
+
export function uriToPath(uri) {
|
|
311
|
+
try { return fileURLToPath(uri); } catch { return String(uri); }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Open a document. LSP has no "read this file" request — a server only knows about
|
|
316
|
+
* documents the client has TOLD it about, so `didOpen` is mandatory before any query
|
|
317
|
+
* about a file will answer.
|
|
318
|
+
*/
|
|
319
|
+
export function openDocument(client, file, text, languageId) {
|
|
320
|
+
client.notify('textDocument/didOpen', {
|
|
321
|
+
textDocument: {
|
|
322
|
+
uri: fileUri(file),
|
|
323
|
+
languageId: languageId || languageFor(file),
|
|
324
|
+
version: 1,
|
|
325
|
+
text,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Re-send a document's content after it changed on disk. */
|
|
331
|
+
export function changeDocument(client, file, text, version) {
|
|
332
|
+
client.notify('textDocument/didChange', {
|
|
333
|
+
textDocument: { uri: fileUri(file), version: version || 2 },
|
|
334
|
+
contentChanges: [{ text }],
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The LSP `languageId` for a file, which servers key their behaviour on. */
|
|
339
|
+
export function languageFor(file) {
|
|
340
|
+
const ext = path.extname(String(file || '')).toLowerCase();
|
|
341
|
+
return {
|
|
342
|
+
'.rs': 'rust',
|
|
343
|
+
'.ts': 'typescript', '.tsx': 'typescriptreact',
|
|
344
|
+
'.js': 'javascript', '.jsx': 'javascriptreact', '.mjs': 'javascript', '.cjs': 'javascript',
|
|
345
|
+
'.py': 'python', '.pyi': 'python',
|
|
346
|
+
'.go': 'go',
|
|
347
|
+
'.c': 'c', '.h': 'c',
|
|
348
|
+
'.cc': 'cpp', '.cpp': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp',
|
|
349
|
+
}[ext] || 'plaintext';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Diagnostics for one file, from the notifications the server has sent.
|
|
354
|
+
*
|
|
355
|
+
* LSP has no "give me diagnostics" request: the server PUSHES them as `publishDiagnostics`
|
|
356
|
+
* when it has computed them, which for a cold server is after it has indexed the project.
|
|
357
|
+
* So this waits for the notification for this file, up to `waitMs`. Returning `[]` on a
|
|
358
|
+
* timeout would claim the file is clean when the truth is "not known yet", so the caller
|
|
359
|
+
* gets `settled` and can say which it was.
|
|
360
|
+
*
|
|
361
|
+
* @returns {Promise<{diagnostics: Array, settled: boolean}>}
|
|
362
|
+
*/
|
|
363
|
+
export async function waitForDiagnostics(client, file, waitMs = 15_000) {
|
|
364
|
+
const want = fileUri(file);
|
|
365
|
+
const latest = () => {
|
|
366
|
+
for (let i = client.notifications.length - 1; i >= 0; i--) {
|
|
367
|
+
const n = client.notifications[i];
|
|
368
|
+
if (n.method === 'textDocument/publishDiagnostics' && n.params && n.params.uri === want) {
|
|
369
|
+
return n.params.diagnostics || [];
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
};
|
|
374
|
+
const now = latest();
|
|
375
|
+
if (now) return { diagnostics: now, settled: true };
|
|
376
|
+
|
|
377
|
+
const deadline = Date.now() + waitMs;
|
|
378
|
+
while (Date.now() < deadline) {
|
|
379
|
+
if (client.dead()) return { diagnostics: [], settled: false };
|
|
380
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
381
|
+
const got = latest();
|
|
382
|
+
if (got) return { diagnostics: got, settled: true };
|
|
383
|
+
}
|
|
384
|
+
// An empty list IS a valid answer (a clean file), so the caller must be able to tell
|
|
385
|
+
// "no problems" from "no answer", which is what `settled` is for.
|
|
386
|
+
return { diagnostics: [], settled: false };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Severity numbers LSP uses, in words. */
|
|
390
|
+
export const SEVERITY_NAMES = ['', 'error', 'warning', 'information', 'hint'];
|
|
391
|
+
|
|
392
|
+
/** One diagnostic as a readable line: `error 12:5 cannot find value \`x\``. */
|
|
393
|
+
export function formatDiagnostic(d, root) {
|
|
394
|
+
const sev = SEVERITY_NAMES[d.severity || 1] || 'info';
|
|
395
|
+
const line = (d.range && d.range.start ? d.range.start.line : 0) + 1;
|
|
396
|
+
const col = (d.range && d.range.start ? d.range.start.character : 0) + 1;
|
|
397
|
+
const where = root ? path.relative(root, d._file || '').split(path.sep).join('/') : (d._file || '');
|
|
398
|
+
const src = d.source ? ` [${d.source}]` : '';
|
|
399
|
+
const msg = String(d.message || '').replace(/\s+/g, ' ').trim();
|
|
400
|
+
return `${sev.padEnd(7)} ${where}${where ? ':' : ''}${line}:${col}${src} ${msg}`;
|
|
401
|
+
}
|
|
@@ -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
|
+
}
|