@hncode/hncode 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +145 -33
- 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 +175 -30
- package/src/config.js +376 -34
- package/src/context-bar.js +237 -0
- package/src/doctor.js +320 -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/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 +143 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session.js +178 -8
- 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/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 +5219 -792
- package/src/web-public/app.js +7 -0
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
|
+
}
|
package/src/notebook.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Jupyter notebook (.ipynb) support: read and edit CELLS rather than the JSON.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// ---------------
|
|
5
|
+
// `tools/edit.js` REFUSED to touch a .ipynb, and the refusal was right: a notebook is JSON,
|
|
6
|
+
// and a snippet replacement inside it either corrupts the structure or lands in the middle
|
|
7
|
+
// of a base64 image. But refusing is only half an answer — it left the model with no way to
|
|
8
|
+
// change a notebook at all, so it would either give up or reach for Bash and do it by hand.
|
|
9
|
+
//
|
|
10
|
+
// What it needs instead is a view of the notebook as cells, and edits expressed against
|
|
11
|
+
// those cells. That is what this module provides:
|
|
12
|
+
//
|
|
13
|
+
// listCells -> the cells, with their index, type and a preview
|
|
14
|
+
// readCell -> one cell's source, in full
|
|
15
|
+
// replaceSource -> replace one cell's source
|
|
16
|
+
// insertCell -> add a cell before/after an index
|
|
17
|
+
// deleteCell -> remove one cell
|
|
18
|
+
//
|
|
19
|
+
// THE ONE INVARIANT THAT MATTERS
|
|
20
|
+
// ------------------------------
|
|
21
|
+
// Everything OUTSIDE `cells[i].source` is preserved byte-for-byte in intent: cell ids,
|
|
22
|
+
// metadata, outputs, execution_count, nbformat, the top-level metadata. A notebook carries
|
|
23
|
+
// state that is not the code — outputs a user may want to keep, a kernel's metadata, cell
|
|
24
|
+
// ids that Jupyter's collaboration features key on — and a tool that rebuilt the document
|
|
25
|
+
// from the parsed object would silently drop the fields it did not know about.
|
|
26
|
+
//
|
|
27
|
+
// So the JSON is PARSED to find what to change, the change is applied to the parsed value,
|
|
28
|
+
// and the parsed value is serialised back. Every field survives because every field is
|
|
29
|
+
// carried by the same object; `JSON.stringify` is given the ORIGINAL indentation so the
|
|
30
|
+
// file's diff stays about the edit rather than about reformatting.
|
|
31
|
+
//
|
|
32
|
+
// A MALFORMED NOTEBOOK IS NOT REPAIRED
|
|
33
|
+
// ------------------------------------
|
|
34
|
+
// If the file does not parse, or has no `cells` array, every function here refuses and says
|
|
35
|
+
// why. Guessing at a broken notebook's structure is how a tool turns "I could not help" into
|
|
36
|
+
// "I destroyed your work".
|
|
37
|
+
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
|
|
40
|
+
/** Notebook formats this understands. A newer nbformat is accepted: the `cells` shape has
|
|
41
|
+
* been stable across 4.x, and refusing a 4.6 file because we know 4.5 would be silly. */
|
|
42
|
+
const MIN_NBFORMAT = 4;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse a notebook.
|
|
46
|
+
*
|
|
47
|
+
* @returns {{ok: true, doc: object, indent: string} | {ok: false, error: string}}
|
|
48
|
+
*/
|
|
49
|
+
export function parseNotebook(text) {
|
|
50
|
+
let doc;
|
|
51
|
+
try { doc = JSON.parse(text); }
|
|
52
|
+
catch (e) { return { ok: false, error: `not valid JSON: ${e.message}` }; }
|
|
53
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
54
|
+
return { ok: false, error: 'the top level is not an object' };
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(doc.cells)) {
|
|
57
|
+
return { ok: false, error: 'there is no `cells` array (is this really a notebook?)' };
|
|
58
|
+
}
|
|
59
|
+
const nbf = Number(doc.nbformat);
|
|
60
|
+
if (Number.isFinite(nbf) && nbf < MIN_NBFORMAT) {
|
|
61
|
+
return { ok: false, error: `nbformat ${nbf} is older than ${MIN_NBFORMAT}, whose cell shape this understands` };
|
|
62
|
+
}
|
|
63
|
+
// Detect the indentation so a re-serialised file does not reformat the whole document.
|
|
64
|
+
// Jupyter writes one space, but a hand-edited or tool-generated file may differ.
|
|
65
|
+
const m = /^([ \t]+)"/m.exec(text);
|
|
66
|
+
return { ok: true, doc, indent: m ? m[1] : ' ' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read and parse a file. */
|
|
70
|
+
export function loadNotebook(file) {
|
|
71
|
+
let text;
|
|
72
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
73
|
+
catch (e) { return { ok: false, error: `could not read ${file}: ${e.message}` }; }
|
|
74
|
+
const parsed = parseNotebook(text);
|
|
75
|
+
if (!parsed.ok) return { ok: false, error: `${file}: ${parsed.error}` };
|
|
76
|
+
return { ...parsed, text };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The source of a cell as one string.
|
|
81
|
+
*
|
|
82
|
+
* Jupyter stores `source` as an ARRAY of lines (each keeping its own newline) in nbformat 4,
|
|
83
|
+
* and as a plain string in some hand-written files. Both spellings appear in the wild, and a
|
|
84
|
+
* caller that only handled the array read `undefined` from the string form.
|
|
85
|
+
*/
|
|
86
|
+
export function cellSource(cell) {
|
|
87
|
+
if (!cell) return '';
|
|
88
|
+
const s = cell.source;
|
|
89
|
+
if (Array.isArray(s)) return s.join('');
|
|
90
|
+
if (typeof s === 'string') return s;
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Split a source string into the array form Jupyter writes, keeping the newlines. */
|
|
95
|
+
export function toSourceLines(text) {
|
|
96
|
+
const s = String(text == null ? '' : text);
|
|
97
|
+
if (!s) return [];
|
|
98
|
+
// `split` with a capture keeps the separators, so `"a\nb\n"` -> `["a\n", "b\n"]`.
|
|
99
|
+
const parts = s.split(/(?<=\n)/);
|
|
100
|
+
return parts.filter((p) => p !== '');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One-line preview of a cell, for `listCells`. */
|
|
104
|
+
function preview(text, max = 60) {
|
|
105
|
+
const flat = String(text).replace(/\s+/g, ' ').trim();
|
|
106
|
+
return flat.length <= max ? flat : flat.slice(0, max - 1) + '\u2026';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* List the cells.
|
|
111
|
+
*
|
|
112
|
+
* @returns {Array<{index:number, id:string, type:string, lines:number, source:string,
|
|
113
|
+
* preview:string, outputs:number, executionCount:number|null}>}
|
|
114
|
+
* `index` is 0-based and is what every other function here takes.
|
|
115
|
+
*/
|
|
116
|
+
export function listCells(doc) {
|
|
117
|
+
return (doc.cells || []).map((c, i) => {
|
|
118
|
+
const src = cellSource(c);
|
|
119
|
+
return {
|
|
120
|
+
index: i,
|
|
121
|
+
id: c.id == null ? '' : String(c.id),
|
|
122
|
+
type: String(c.cell_type || 'unknown'),
|
|
123
|
+
lines: src ? src.split('\n').length : 0,
|
|
124
|
+
source: src,
|
|
125
|
+
preview: preview(src),
|
|
126
|
+
outputs: Array.isArray(c.outputs) ? c.outputs.length : 0,
|
|
127
|
+
executionCount: c.execution_count == null ? null : c.execution_count,
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Render the cell list for a tool result or a panel. */
|
|
133
|
+
export function describeCells(doc, file) {
|
|
134
|
+
const cells = listCells(doc);
|
|
135
|
+
const header = file ? `${file} — ${cells.length} cell(s)` : `${cells.length} cell(s)`;
|
|
136
|
+
if (!cells.length) return [header, '', 'The notebook has no cells.'];
|
|
137
|
+
const lines = [header, ''];
|
|
138
|
+
for (const c of cells) {
|
|
139
|
+
const meta = [];
|
|
140
|
+
if (c.type === 'code' && c.executionCount != null) meta.push(`[${c.executionCount}]`);
|
|
141
|
+
if (c.outputs) meta.push(`${c.outputs} output(s)`);
|
|
142
|
+
lines.push(` ${String(c.index).padStart(3)} ${c.type.padEnd(8)} ${c.lines} line(s) ${meta.join(' ')}`);
|
|
143
|
+
if (c.preview) lines.push(` ${c.preview}`);
|
|
144
|
+
}
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Serialise a document back, preserving the file's own indentation. */
|
|
149
|
+
export function serializeNotebook(doc, indent = ' ') {
|
|
150
|
+
// A notebook written by Jupyter ends with a newline; matching that keeps the file's last
|
|
151
|
+
// line unchanged, so a diff does not show a spurious "".
|
|
152
|
+
return JSON.stringify(doc, null, indent) + '\n';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Save a document, atomically, so a crash cannot leave a half-written notebook. */
|
|
156
|
+
export function saveNotebook(file, doc, indent = ' ') {
|
|
157
|
+
const text = serializeNotebook(doc, indent);
|
|
158
|
+
const tmp = `${file}.${process.pid}.nb.tmp`;
|
|
159
|
+
try {
|
|
160
|
+
fs.writeFileSync(tmp, text, 'utf8');
|
|
161
|
+
fs.renameSync(tmp, file);
|
|
162
|
+
return { ok: true };
|
|
163
|
+
} catch (e) {
|
|
164
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
165
|
+
return { ok: false, error: e.message };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** True when an index addresses a real cell. Reported by name so the message is actionable. */
|
|
170
|
+
export function checkIndex(doc, index) {
|
|
171
|
+
const n = (doc.cells || []).length;
|
|
172
|
+
if (!Number.isInteger(index)) return { ok: false, error: `cell index must be an integer (got ${JSON.stringify(index)})` };
|
|
173
|
+
if (index < 0 || index >= n) return { ok: false, error: `cell index ${index} is out of range (the notebook has ${n} cell(s): 0-${n - 1})` };
|
|
174
|
+
return { ok: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Replace one cell's source, leaving everything else about it — outputs, metadata, id —
|
|
179
|
+
* exactly as it was.
|
|
180
|
+
*
|
|
181
|
+
* Clearing `outputs` would be the tempting "correct" thing, since the old output no longer
|
|
182
|
+
* matches the new code. It is the wrong default: the agent is editing SOURCE, and a user who
|
|
183
|
+
* asked for one line to change does not expect their stored outputs to be dropped. The
|
|
184
|
+
* unchanged-output state is what Jupyter itself shows when you edit a cell without running
|
|
185
|
+
* it, so it is also the least surprising.
|
|
186
|
+
*
|
|
187
|
+
* @returns {{ok: true, cell: object} | {ok: false, error: string}}
|
|
188
|
+
*/
|
|
189
|
+
export function replaceSource(doc, index, source) {
|
|
190
|
+
const idx = checkIndex(doc, index);
|
|
191
|
+
if (!idx.ok) return idx;
|
|
192
|
+
const cell = doc.cells[index];
|
|
193
|
+
cell.source = toSourceLines(source);
|
|
194
|
+
return { ok: true, cell };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Insert a cell.
|
|
199
|
+
*
|
|
200
|
+
* @param {number} index insert BEFORE this index; `doc.cells.length` appends.
|
|
201
|
+
*/
|
|
202
|
+
export function insertCell(doc, index, source, type = 'code') {
|
|
203
|
+
const n = (doc.cells || []).length;
|
|
204
|
+
if (!Number.isInteger(index) || index < 0 || index > n) {
|
|
205
|
+
return { ok: false, error: `insert index must be 0-${n} (got ${JSON.stringify(index)})` };
|
|
206
|
+
}
|
|
207
|
+
const kind = type === 'markdown' || type === 'raw' ? type : 'code';
|
|
208
|
+
const cell = { cell_type: kind, metadata: {}, source: toSourceLines(source) };
|
|
209
|
+
if (kind === 'code') { cell.outputs = []; cell.execution_count = null; }
|
|
210
|
+
// nbformat 4.5+ requires every cell to carry an id. Adding one keeps a notebook that had
|
|
211
|
+
// ids from becoming invalid; a notebook that predates them is left alone.
|
|
212
|
+
const anyId = (doc.cells || []).some((c) => c && c.id);
|
|
213
|
+
if (anyId || Number(doc.nbformat_minor) >= 5) cell.id = newCellId();
|
|
214
|
+
doc.cells.splice(index, 0, cell);
|
|
215
|
+
return { ok: true, cell, index };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Remove one cell. */
|
|
219
|
+
export function deleteCell(doc, index) {
|
|
220
|
+
const idx = checkIndex(doc, index);
|
|
221
|
+
if (!idx.ok) return idx;
|
|
222
|
+
const [removed] = doc.cells.splice(index, 1);
|
|
223
|
+
return { ok: true, removed };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A short random id in the shape Jupyter uses (8 lowercase hex/alnum characters). */
|
|
227
|
+
export function newCellId() {
|
|
228
|
+
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
229
|
+
let out = '';
|
|
230
|
+
for (let i = 0; i < 8; i++) out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
231
|
+
return out;
|
|
232
|
+
}
|