@oh-my-pi/pi-coding-agent 5.6.7 → 5.6.70
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/CHANGELOG.md +7 -0
- package/package.json +5 -5
- package/src/core/python-kernel.test.ts +146 -234
- package/src/core/python-kernel.ts +7 -3
- package/src/core/python-modules.test.ts +102 -0
- package/src/core/python-modules.ts +110 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.6.70] - 2026-01-18
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- Added support for loading Python prelude extension modules from user and project directories
|
|
9
|
+
- Added automatic discovery of Python modules from `.omp/modules` and `.pi/modules` directories
|
|
10
|
+
- Added prioritized module loading with project-level modules overriding user-level modules
|
|
11
|
+
|
|
5
12
|
## [5.6.7] - 2026-01-18
|
|
6
13
|
|
|
7
14
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
3
|
-
"version": "5.6.
|
|
3
|
+
"version": "5.6.70",
|
|
4
4
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"ompConfig": {
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"prepublishOnly": "bun run generate-template && bun run clean && bun run build"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@oh-my-pi/pi-agent-core": "5.6.
|
|
43
|
-
"@oh-my-pi/pi-ai": "5.6.
|
|
44
|
-
"@oh-my-pi/pi-git-tool": "5.6.
|
|
45
|
-
"@oh-my-pi/pi-tui": "5.6.
|
|
42
|
+
"@oh-my-pi/pi-agent-core": "5.6.70",
|
|
43
|
+
"@oh-my-pi/pi-ai": "5.6.70",
|
|
44
|
+
"@oh-my-pi/pi-git-tool": "5.6.70",
|
|
45
|
+
"@oh-my-pi/pi-tui": "5.6.70",
|
|
46
46
|
"@openai/agents": "^0.3.7",
|
|
47
47
|
"@silvia-odwyer/photon-node": "^0.3.4",
|
|
48
48
|
"@sinclair/typebox": "^0.34.46",
|
|
@@ -67,6 +67,39 @@ function decodeMessage(data: ArrayBuffer): JupyterMessage {
|
|
|
67
67
|
return JSON.parse(msgText) as JupyterMessage;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
function sendOkExecution(ws: FakeWebSocket, msgId: string, executionCount = 1) {
|
|
71
|
+
const reply: JupyterMessage = {
|
|
72
|
+
channel: "shell",
|
|
73
|
+
header: {
|
|
74
|
+
msg_id: `reply-${msgId}`,
|
|
75
|
+
session: "session",
|
|
76
|
+
username: "omp",
|
|
77
|
+
date: new Date().toISOString(),
|
|
78
|
+
msg_type: "execute_reply",
|
|
79
|
+
version: "5.5",
|
|
80
|
+
},
|
|
81
|
+
parent_header: { msg_id: msgId },
|
|
82
|
+
metadata: {},
|
|
83
|
+
content: { status: "ok", execution_count: executionCount },
|
|
84
|
+
};
|
|
85
|
+
const status: JupyterMessage = {
|
|
86
|
+
channel: "iopub",
|
|
87
|
+
header: {
|
|
88
|
+
msg_id: `status-${msgId}`,
|
|
89
|
+
session: "session",
|
|
90
|
+
username: "omp",
|
|
91
|
+
date: new Date().toISOString(),
|
|
92
|
+
msg_type: "status",
|
|
93
|
+
version: "5.5",
|
|
94
|
+
},
|
|
95
|
+
parent_header: { msg_id: msgId },
|
|
96
|
+
metadata: {},
|
|
97
|
+
content: { execution_state: "idle" },
|
|
98
|
+
};
|
|
99
|
+
ws.onmessage?.({ data: encodeMessage(reply) });
|
|
100
|
+
ws.onmessage?.({ data: encodeMessage(status) });
|
|
101
|
+
}
|
|
102
|
+
|
|
70
103
|
class FakeWebSocket {
|
|
71
104
|
static OPEN = 1;
|
|
72
105
|
static CLOSED = 3;
|
|
@@ -149,106 +182,7 @@ describe("PythonKernel (external gateway)", () => {
|
|
|
149
182
|
});
|
|
150
183
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
151
184
|
|
|
152
|
-
|
|
153
|
-
responseQueue.push((msgId, ws) => {
|
|
154
|
-
const reply: JupyterMessage = {
|
|
155
|
-
channel: "shell",
|
|
156
|
-
header: {
|
|
157
|
-
msg_id: "reply-1",
|
|
158
|
-
session: "session",
|
|
159
|
-
username: "omp",
|
|
160
|
-
date: new Date().toISOString(),
|
|
161
|
-
msg_type: "execute_reply",
|
|
162
|
-
version: "5.5",
|
|
163
|
-
},
|
|
164
|
-
parent_header: { msg_id: msgId },
|
|
165
|
-
metadata: {},
|
|
166
|
-
content: { status: "ok", execution_count: 1 },
|
|
167
|
-
};
|
|
168
|
-
const status: JupyterMessage = {
|
|
169
|
-
channel: "iopub",
|
|
170
|
-
header: {
|
|
171
|
-
msg_id: "status-1",
|
|
172
|
-
session: "session",
|
|
173
|
-
username: "omp",
|
|
174
|
-
date: new Date().toISOString(),
|
|
175
|
-
msg_type: "status",
|
|
176
|
-
version: "5.5",
|
|
177
|
-
},
|
|
178
|
-
parent_header: { msg_id: msgId },
|
|
179
|
-
metadata: {},
|
|
180
|
-
content: { execution_state: "idle" },
|
|
181
|
-
};
|
|
182
|
-
ws.onmessage?.({ data: encodeMessage(reply) });
|
|
183
|
-
ws.onmessage?.({ data: encodeMessage(status) });
|
|
184
|
-
});
|
|
185
|
-
responseQueue.push((msgId, ws) => {
|
|
186
|
-
const stream: JupyterMessage = {
|
|
187
|
-
channel: "iopub",
|
|
188
|
-
header: {
|
|
189
|
-
msg_id: "stream-1",
|
|
190
|
-
session: "session",
|
|
191
|
-
username: "omp",
|
|
192
|
-
date: new Date().toISOString(),
|
|
193
|
-
msg_type: "stream",
|
|
194
|
-
version: "5.5",
|
|
195
|
-
},
|
|
196
|
-
parent_header: { msg_id: msgId },
|
|
197
|
-
metadata: {},
|
|
198
|
-
content: { text: "hello\n" },
|
|
199
|
-
};
|
|
200
|
-
const display: JupyterMessage = {
|
|
201
|
-
channel: "iopub",
|
|
202
|
-
header: {
|
|
203
|
-
msg_id: "display-1",
|
|
204
|
-
session: "session",
|
|
205
|
-
username: "omp",
|
|
206
|
-
date: new Date().toISOString(),
|
|
207
|
-
msg_type: "execute_result",
|
|
208
|
-
version: "5.5",
|
|
209
|
-
},
|
|
210
|
-
parent_header: { msg_id: msgId },
|
|
211
|
-
metadata: {},
|
|
212
|
-
content: {
|
|
213
|
-
data: {
|
|
214
|
-
"text/plain": "result",
|
|
215
|
-
"application/json": { answer: 42 },
|
|
216
|
-
},
|
|
217
|
-
},
|
|
218
|
-
};
|
|
219
|
-
const reply: JupyterMessage = {
|
|
220
|
-
channel: "shell",
|
|
221
|
-
header: {
|
|
222
|
-
msg_id: "reply-2",
|
|
223
|
-
session: "session",
|
|
224
|
-
username: "omp",
|
|
225
|
-
date: new Date().toISOString(),
|
|
226
|
-
msg_type: "execute_reply",
|
|
227
|
-
version: "5.5",
|
|
228
|
-
},
|
|
229
|
-
parent_header: { msg_id: msgId },
|
|
230
|
-
metadata: {},
|
|
231
|
-
content: { status: "ok", execution_count: 2 },
|
|
232
|
-
};
|
|
233
|
-
const status: JupyterMessage = {
|
|
234
|
-
channel: "iopub",
|
|
235
|
-
header: {
|
|
236
|
-
msg_id: "status-2",
|
|
237
|
-
session: "session",
|
|
238
|
-
username: "omp",
|
|
239
|
-
date: new Date().toISOString(),
|
|
240
|
-
msg_type: "status",
|
|
241
|
-
version: "5.5",
|
|
242
|
-
},
|
|
243
|
-
parent_header: { msg_id: msgId },
|
|
244
|
-
metadata: {},
|
|
245
|
-
content: { execution_state: "idle" },
|
|
246
|
-
};
|
|
247
|
-
ws.onmessage?.({ data: encodeMessage(stream) });
|
|
248
|
-
ws.onmessage?.({ data: encodeMessage(display) });
|
|
249
|
-
ws.onmessage?.({ data: encodeMessage(reply) });
|
|
250
|
-
ws.onmessage?.({ data: encodeMessage(status) });
|
|
251
|
-
});
|
|
185
|
+
let preludeSeen = false;
|
|
252
186
|
|
|
253
187
|
const kernelPromise = PythonKernel.start({ cwd: "/" });
|
|
254
188
|
await Bun.sleep(10);
|
|
@@ -256,11 +190,84 @@ describe("PythonKernel (external gateway)", () => {
|
|
|
256
190
|
if (!ws) throw new Error("WebSocket not initialized");
|
|
257
191
|
ws.setSendHandler((data) => {
|
|
258
192
|
const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
|
|
259
|
-
const
|
|
260
|
-
if (!
|
|
261
|
-
|
|
193
|
+
const code = String(msg.content.code ?? "");
|
|
194
|
+
if (!preludeSeen) {
|
|
195
|
+
expect(code).toBe(PYTHON_PRELUDE);
|
|
196
|
+
preludeSeen = true;
|
|
197
|
+
sendOkExecution(ws, msg.header.msg_id);
|
|
198
|
+
return;
|
|
262
199
|
}
|
|
263
|
-
|
|
200
|
+
|
|
201
|
+
if (code === "print('hello')") {
|
|
202
|
+
const stream: JupyterMessage = {
|
|
203
|
+
channel: "iopub",
|
|
204
|
+
header: {
|
|
205
|
+
msg_id: "stream-1",
|
|
206
|
+
session: "session",
|
|
207
|
+
username: "omp",
|
|
208
|
+
date: new Date().toISOString(),
|
|
209
|
+
msg_type: "stream",
|
|
210
|
+
version: "5.5",
|
|
211
|
+
},
|
|
212
|
+
parent_header: { msg_id: msg.header.msg_id },
|
|
213
|
+
metadata: {},
|
|
214
|
+
content: { text: "hello\n" },
|
|
215
|
+
};
|
|
216
|
+
const display: JupyterMessage = {
|
|
217
|
+
channel: "iopub",
|
|
218
|
+
header: {
|
|
219
|
+
msg_id: "display-1",
|
|
220
|
+
session: "session",
|
|
221
|
+
username: "omp",
|
|
222
|
+
date: new Date().toISOString(),
|
|
223
|
+
msg_type: "execute_result",
|
|
224
|
+
version: "5.5",
|
|
225
|
+
},
|
|
226
|
+
parent_header: { msg_id: msg.header.msg_id },
|
|
227
|
+
metadata: {},
|
|
228
|
+
content: {
|
|
229
|
+
data: {
|
|
230
|
+
"text/plain": "result",
|
|
231
|
+
"application/json": { answer: 42 },
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
const reply: JupyterMessage = {
|
|
236
|
+
channel: "shell",
|
|
237
|
+
header: {
|
|
238
|
+
msg_id: "reply-2",
|
|
239
|
+
session: "session",
|
|
240
|
+
username: "omp",
|
|
241
|
+
date: new Date().toISOString(),
|
|
242
|
+
msg_type: "execute_reply",
|
|
243
|
+
version: "5.5",
|
|
244
|
+
},
|
|
245
|
+
parent_header: { msg_id: msg.header.msg_id },
|
|
246
|
+
metadata: {},
|
|
247
|
+
content: { status: "ok", execution_count: 2 },
|
|
248
|
+
};
|
|
249
|
+
const status: JupyterMessage = {
|
|
250
|
+
channel: "iopub",
|
|
251
|
+
header: {
|
|
252
|
+
msg_id: "status-2",
|
|
253
|
+
session: "session",
|
|
254
|
+
username: "omp",
|
|
255
|
+
date: new Date().toISOString(),
|
|
256
|
+
msg_type: "status",
|
|
257
|
+
version: "5.5",
|
|
258
|
+
},
|
|
259
|
+
parent_header: { msg_id: msg.header.msg_id },
|
|
260
|
+
metadata: {},
|
|
261
|
+
content: { execution_state: "idle" },
|
|
262
|
+
};
|
|
263
|
+
ws.onmessage?.({ data: encodeMessage(stream) });
|
|
264
|
+
ws.onmessage?.({ data: encodeMessage(display) });
|
|
265
|
+
ws.onmessage?.({ data: encodeMessage(reply) });
|
|
266
|
+
ws.onmessage?.({ data: encodeMessage(status) });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
sendOkExecution(ws, msg.header.msg_id);
|
|
264
271
|
});
|
|
265
272
|
|
|
266
273
|
const kernel = await kernelPromise;
|
|
@@ -300,40 +307,7 @@ describe("PythonKernel (external gateway)", () => {
|
|
|
300
307
|
});
|
|
301
308
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
302
309
|
|
|
303
|
-
|
|
304
|
-
(msgId, ws) => {
|
|
305
|
-
const reply: JupyterMessage = {
|
|
306
|
-
channel: "shell",
|
|
307
|
-
header: {
|
|
308
|
-
msg_id: "reply-prelude",
|
|
309
|
-
session: "session",
|
|
310
|
-
username: "omp",
|
|
311
|
-
date: new Date().toISOString(),
|
|
312
|
-
msg_type: "execute_reply",
|
|
313
|
-
version: "5.5",
|
|
314
|
-
},
|
|
315
|
-
parent_header: { msg_id: msgId },
|
|
316
|
-
metadata: {},
|
|
317
|
-
content: { status: "ok", execution_count: 1 },
|
|
318
|
-
};
|
|
319
|
-
const status: JupyterMessage = {
|
|
320
|
-
channel: "iopub",
|
|
321
|
-
header: {
|
|
322
|
-
msg_id: "status-prelude",
|
|
323
|
-
session: "session",
|
|
324
|
-
username: "omp",
|
|
325
|
-
date: new Date().toISOString(),
|
|
326
|
-
msg_type: "status",
|
|
327
|
-
version: "5.5",
|
|
328
|
-
},
|
|
329
|
-
parent_header: { msg_id: msgId },
|
|
330
|
-
metadata: {},
|
|
331
|
-
content: { execution_state: "idle" },
|
|
332
|
-
};
|
|
333
|
-
ws.onmessage?.({ data: encodeMessage(reply) });
|
|
334
|
-
ws.onmessage?.({ data: encodeMessage(status) });
|
|
335
|
-
},
|
|
336
|
-
];
|
|
310
|
+
let preludeSeen = false;
|
|
337
311
|
|
|
338
312
|
const kernelPromise = PythonKernel.start({ cwd: "/" });
|
|
339
313
|
await Bun.sleep(10);
|
|
@@ -341,11 +315,12 @@ describe("PythonKernel (external gateway)", () => {
|
|
|
341
315
|
if (!ws) throw new Error("WebSocket not initialized");
|
|
342
316
|
ws.setSendHandler((data) => {
|
|
343
317
|
const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
|
|
344
|
-
const
|
|
345
|
-
if (!
|
|
346
|
-
|
|
318
|
+
const code = String(msg.content.code ?? "");
|
|
319
|
+
if (!preludeSeen) {
|
|
320
|
+
expect(code).toBe(PYTHON_PRELUDE);
|
|
321
|
+
preludeSeen = true;
|
|
347
322
|
}
|
|
348
|
-
|
|
323
|
+
sendOkExecution(ws, msg.header.msg_id);
|
|
349
324
|
});
|
|
350
325
|
|
|
351
326
|
const kernel = await kernelPromise;
|
|
@@ -368,40 +343,7 @@ describe("PythonKernel (external gateway)", () => {
|
|
|
368
343
|
});
|
|
369
344
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
370
345
|
|
|
371
|
-
|
|
372
|
-
(msgId, ws) => {
|
|
373
|
-
const reply: JupyterMessage = {
|
|
374
|
-
channel: "shell",
|
|
375
|
-
header: {
|
|
376
|
-
msg_id: "reply-prelude",
|
|
377
|
-
session: "session",
|
|
378
|
-
username: "omp",
|
|
379
|
-
date: new Date().toISOString(),
|
|
380
|
-
msg_type: "execute_reply",
|
|
381
|
-
version: "5.5",
|
|
382
|
-
},
|
|
383
|
-
parent_header: { msg_id: msgId },
|
|
384
|
-
metadata: {},
|
|
385
|
-
content: { status: "ok", execution_count: 1 },
|
|
386
|
-
};
|
|
387
|
-
const status: JupyterMessage = {
|
|
388
|
-
channel: "iopub",
|
|
389
|
-
header: {
|
|
390
|
-
msg_id: "status-prelude",
|
|
391
|
-
session: "session",
|
|
392
|
-
username: "omp",
|
|
393
|
-
date: new Date().toISOString(),
|
|
394
|
-
msg_type: "status",
|
|
395
|
-
version: "5.5",
|
|
396
|
-
},
|
|
397
|
-
parent_header: { msg_id: msgId },
|
|
398
|
-
metadata: {},
|
|
399
|
-
content: { execution_state: "idle" },
|
|
400
|
-
};
|
|
401
|
-
ws.onmessage?.({ data: encodeMessage(reply) });
|
|
402
|
-
ws.onmessage?.({ data: encodeMessage(status) });
|
|
403
|
-
},
|
|
404
|
-
];
|
|
346
|
+
let preludeSeen = false;
|
|
405
347
|
|
|
406
348
|
const kernelPromise = PythonKernel.start({ cwd: "/" });
|
|
407
349
|
await Bun.sleep(10);
|
|
@@ -409,12 +351,12 @@ describe("PythonKernel (external gateway)", () => {
|
|
|
409
351
|
if (!ws) throw new Error("WebSocket not initialized");
|
|
410
352
|
ws.setSendHandler((data) => {
|
|
411
353
|
const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
|
|
412
|
-
const
|
|
413
|
-
if (!
|
|
414
|
-
|
|
354
|
+
const code = String(msg.content.code ?? "");
|
|
355
|
+
if (!preludeSeen) {
|
|
356
|
+
expect(code).toBe(PYTHON_PRELUDE);
|
|
357
|
+
preludeSeen = true;
|
|
415
358
|
}
|
|
416
|
-
|
|
417
|
-
handler(msg.header.msg_id, ws);
|
|
359
|
+
sendOkExecution(ws, msg.header.msg_id);
|
|
418
360
|
});
|
|
419
361
|
|
|
420
362
|
const kernel = await kernelPromise;
|
|
@@ -441,40 +383,23 @@ describe("PythonKernel (external gateway)", () => {
|
|
|
441
383
|
];
|
|
442
384
|
const payload = JSON.stringify(docs);
|
|
443
385
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
channel: "iopub",
|
|
462
|
-
header: {
|
|
463
|
-
msg_id: "status-prelude",
|
|
464
|
-
session: "session",
|
|
465
|
-
username: "omp",
|
|
466
|
-
date: new Date().toISOString(),
|
|
467
|
-
msg_type: "status",
|
|
468
|
-
version: "5.5",
|
|
469
|
-
},
|
|
470
|
-
parent_header: { msg_id: msgId },
|
|
471
|
-
metadata: {},
|
|
472
|
-
content: { execution_state: "idle" },
|
|
473
|
-
};
|
|
474
|
-
ws.onmessage?.({ data: encodeMessage(reply) });
|
|
475
|
-
ws.onmessage?.({ data: encodeMessage(status) });
|
|
476
|
-
},
|
|
477
|
-
(msgId, ws) => {
|
|
386
|
+
let preludeSeen = false;
|
|
387
|
+
|
|
388
|
+
const kernelPromise = PythonKernel.start({ cwd: "/" });
|
|
389
|
+
await Bun.sleep(10);
|
|
390
|
+
const ws = FakeWebSocket.lastInstance;
|
|
391
|
+
if (!ws) throw new Error("WebSocket not initialized");
|
|
392
|
+
ws.setSendHandler((data) => {
|
|
393
|
+
const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
|
|
394
|
+
const code = String(msg.content.code ?? "");
|
|
395
|
+
if (!preludeSeen) {
|
|
396
|
+
expect(code).toBe(PYTHON_PRELUDE);
|
|
397
|
+
preludeSeen = true;
|
|
398
|
+
sendOkExecution(ws, msg.header.msg_id);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (code.includes("__omp_prelude_docs__")) {
|
|
478
403
|
const stream: JupyterMessage = {
|
|
479
404
|
channel: "iopub",
|
|
480
405
|
header: {
|
|
@@ -485,7 +410,7 @@ describe("PythonKernel (external gateway)", () => {
|
|
|
485
410
|
msg_type: "stream",
|
|
486
411
|
version: "5.5",
|
|
487
412
|
},
|
|
488
|
-
parent_header: { msg_id:
|
|
413
|
+
parent_header: { msg_id: msg.header.msg_id },
|
|
489
414
|
metadata: {},
|
|
490
415
|
content: { text: `${payload}\n` },
|
|
491
416
|
};
|
|
@@ -499,7 +424,7 @@ describe("PythonKernel (external gateway)", () => {
|
|
|
499
424
|
msg_type: "execute_reply",
|
|
500
425
|
version: "5.5",
|
|
501
426
|
},
|
|
502
|
-
parent_header: { msg_id:
|
|
427
|
+
parent_header: { msg_id: msg.header.msg_id },
|
|
503
428
|
metadata: {},
|
|
504
429
|
content: { status: "ok", execution_count: 2 },
|
|
505
430
|
};
|
|
@@ -513,30 +438,17 @@ describe("PythonKernel (external gateway)", () => {
|
|
|
513
438
|
msg_type: "status",
|
|
514
439
|
version: "5.5",
|
|
515
440
|
},
|
|
516
|
-
parent_header: { msg_id:
|
|
441
|
+
parent_header: { msg_id: msg.header.msg_id },
|
|
517
442
|
metadata: {},
|
|
518
443
|
content: { execution_state: "idle" },
|
|
519
444
|
};
|
|
520
445
|
ws.onmessage?.({ data: encodeMessage(stream) });
|
|
521
446
|
ws.onmessage?.({ data: encodeMessage(reply) });
|
|
522
447
|
ws.onmessage?.({ data: encodeMessage(status) });
|
|
523
|
-
|
|
524
|
-
];
|
|
525
|
-
|
|
526
|
-
const kernelPromise = PythonKernel.start({ cwd: "/" });
|
|
527
|
-
await Bun.sleep(10);
|
|
528
|
-
const ws = FakeWebSocket.lastInstance;
|
|
529
|
-
if (!ws) throw new Error("WebSocket not initialized");
|
|
530
|
-
ws.setSendHandler((data) => {
|
|
531
|
-
const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
|
|
532
|
-
const handler = responseQueue.shift();
|
|
533
|
-
if (!handler) {
|
|
534
|
-
throw new Error(`Unexpected message: ${msg.header.msg_type}`);
|
|
448
|
+
return;
|
|
535
449
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
handler(msg.header.msg_id, ws);
|
|
450
|
+
|
|
451
|
+
sendOkExecution(ws, msg.header.msg_id);
|
|
540
452
|
});
|
|
541
453
|
|
|
542
454
|
const kernel = await kernelPromise;
|
|
@@ -6,6 +6,7 @@ import { getShellConfig, killProcessTree } from "../utils/shell";
|
|
|
6
6
|
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
|
|
7
7
|
import { logger } from "./logger";
|
|
8
8
|
import { acquireSharedGateway, releaseSharedGateway } from "./python-gateway-coordinator";
|
|
9
|
+
import { loadPythonModules } from "./python-modules";
|
|
9
10
|
import { PYTHON_PRELUDE } from "./python-prelude";
|
|
10
11
|
import { htmlToBasicMarkdown } from "./tools/web-scrapers/types";
|
|
11
12
|
import { ScopeSignal } from "./utils";
|
|
@@ -515,7 +516,7 @@ export class PythonKernel {
|
|
|
515
516
|
|
|
516
517
|
const externalConfig = getExternalGatewayConfig();
|
|
517
518
|
if (externalConfig) {
|
|
518
|
-
return PythonKernel.startWithExternalGateway(externalConfig);
|
|
519
|
+
return PythonKernel.startWithExternalGateway(externalConfig, options.cwd);
|
|
519
520
|
}
|
|
520
521
|
|
|
521
522
|
// Try shared gateway first (unless explicitly disabled)
|
|
@@ -535,7 +536,7 @@ export class PythonKernel {
|
|
|
535
536
|
return PythonKernel.startWithLocalGateway(options);
|
|
536
537
|
}
|
|
537
538
|
|
|
538
|
-
private static async startWithExternalGateway(config: ExternalGatewayConfig): Promise<PythonKernel> {
|
|
539
|
+
private static async startWithExternalGateway(config: ExternalGatewayConfig, cwd: string): Promise<PythonKernel> {
|
|
539
540
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
540
541
|
if (config.token) {
|
|
541
542
|
headers.Authorization = `token ${config.token}`;
|
|
@@ -563,6 +564,7 @@ export class PythonKernel {
|
|
|
563
564
|
if (preludeResult.cancelled || preludeResult.status === "error") {
|
|
564
565
|
throw new Error("Failed to initialize Python kernel prelude");
|
|
565
566
|
}
|
|
567
|
+
await loadPythonModules(kernel, { cwd });
|
|
566
568
|
return kernel;
|
|
567
569
|
} catch (err: unknown) {
|
|
568
570
|
await kernel.shutdown();
|
|
@@ -570,7 +572,7 @@ export class PythonKernel {
|
|
|
570
572
|
}
|
|
571
573
|
}
|
|
572
574
|
|
|
573
|
-
private static async startWithSharedGateway(gatewayUrl: string,
|
|
575
|
+
private static async startWithSharedGateway(gatewayUrl: string, cwd: string): Promise<PythonKernel> {
|
|
574
576
|
const createResponse = await fetch(`${gatewayUrl}/api/kernels`, {
|
|
575
577
|
method: "POST",
|
|
576
578
|
headers: { "Content-Type": "application/json" },
|
|
@@ -594,6 +596,7 @@ export class PythonKernel {
|
|
|
594
596
|
if (preludeResult.cancelled || preludeResult.status === "error") {
|
|
595
597
|
throw new Error("Failed to initialize Python kernel prelude");
|
|
596
598
|
}
|
|
599
|
+
await loadPythonModules(kernel, { cwd });
|
|
597
600
|
return kernel;
|
|
598
601
|
} catch (err: unknown) {
|
|
599
602
|
await kernel.shutdown();
|
|
@@ -709,6 +712,7 @@ export class PythonKernel {
|
|
|
709
712
|
if (preludeResult.cancelled || preludeResult.status === "error") {
|
|
710
713
|
throw new Error("Failed to initialize Python kernel prelude");
|
|
711
714
|
}
|
|
715
|
+
await loadPythonModules(kernel, { cwd: options.cwd });
|
|
712
716
|
return kernel;
|
|
713
717
|
} catch (err: unknown) {
|
|
714
718
|
await kernel.shutdown();
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, join, resolve } from "node:path";
|
|
5
|
+
import { discoverPythonModules, loadPythonModules, type PythonModuleExecutor } from "./python-modules";
|
|
6
|
+
|
|
7
|
+
const fixturesDir = resolve(__dirname, "../../test/fixtures/python-modules");
|
|
8
|
+
|
|
9
|
+
const readFixture = (name: string): string => readFileSync(join(fixturesDir, name), "utf-8");
|
|
10
|
+
|
|
11
|
+
const writeModule = (dir: string, name: string, tag: string) => {
|
|
12
|
+
mkdirSync(dir, { recursive: true });
|
|
13
|
+
const base = readFixture(name);
|
|
14
|
+
writeFileSync(join(dir, name), `${base}\n# ${tag}`);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const createTempRoot = () => mkdtempSync(join(tmpdir(), "omp-python-modules-"));
|
|
18
|
+
|
|
19
|
+
describe("python modules", () => {
|
|
20
|
+
let tempRoot: string | null = null;
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
if (tempRoot) {
|
|
24
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
tempRoot = null;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("discovers modules with project override and sorted order", async () => {
|
|
30
|
+
tempRoot = createTempRoot();
|
|
31
|
+
const homeDir = join(tempRoot, "home");
|
|
32
|
+
const cwd = join(tempRoot, "project");
|
|
33
|
+
|
|
34
|
+
writeModule(join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp");
|
|
35
|
+
writeModule(join(homeDir, ".pi", "agent", "modules"), "beta.py", "user-pi");
|
|
36
|
+
writeModule(join(homeDir, ".pi", "agent", "modules"), "delta.py", "user-pi");
|
|
37
|
+
|
|
38
|
+
writeModule(join(cwd, ".omp", "modules"), "alpha.py", "project-omp");
|
|
39
|
+
writeModule(join(cwd, ".omp", "modules"), "beta.py", "project-omp");
|
|
40
|
+
writeModule(join(cwd, ".pi", "modules"), "gamma.py", "project-pi");
|
|
41
|
+
|
|
42
|
+
const modules = await discoverPythonModules({ cwd, homeDir });
|
|
43
|
+
const names = modules.map((module) => basename(module.path));
|
|
44
|
+
expect(names).toEqual(["alpha.py", "beta.py", "delta.py", "gamma.py"]);
|
|
45
|
+
expect(modules.map((module) => ({ name: basename(module.path), source: module.source }))).toEqual([
|
|
46
|
+
{ name: "alpha.py", source: "project" },
|
|
47
|
+
{ name: "beta.py", source: "project" },
|
|
48
|
+
{ name: "delta.py", source: "user" },
|
|
49
|
+
{ name: "gamma.py", source: "project" },
|
|
50
|
+
]);
|
|
51
|
+
expect(modules.find((module) => module.path.endsWith("alpha.py"))?.content).toContain("project-omp");
|
|
52
|
+
expect(modules.find((module) => module.path.endsWith("delta.py"))?.content).toContain("user-pi");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("loads modules in sorted order with silent execution", async () => {
|
|
56
|
+
tempRoot = createTempRoot();
|
|
57
|
+
const homeDir = join(tempRoot, "home");
|
|
58
|
+
const cwd = join(tempRoot, "project");
|
|
59
|
+
|
|
60
|
+
writeModule(join(homeDir, ".omp", "agent", "modules"), "beta.py", "user-omp");
|
|
61
|
+
writeModule(join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp");
|
|
62
|
+
|
|
63
|
+
const calls: Array<{ name: string; options?: { silent?: boolean; storeHistory?: boolean } }> = [];
|
|
64
|
+
const executor: PythonModuleExecutor = {
|
|
65
|
+
execute: async (code: string, options?: { silent?: boolean; storeHistory?: boolean }) => {
|
|
66
|
+
const name = code.includes("def alpha") ? "alpha" : "beta";
|
|
67
|
+
calls.push({ name, options });
|
|
68
|
+
return { status: "ok", cancelled: false };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
await loadPythonModules(executor, { cwd, homeDir });
|
|
73
|
+
expect(calls.map((call) => call.name)).toEqual(["alpha", "beta"]);
|
|
74
|
+
for (const call of calls) {
|
|
75
|
+
expect(call.options).toEqual({ silent: true, storeHistory: false });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("fails fast when a module fails to execute", async () => {
|
|
80
|
+
tempRoot = createTempRoot();
|
|
81
|
+
const homeDir = join(tempRoot, "home");
|
|
82
|
+
const cwd = join(tempRoot, "project");
|
|
83
|
+
|
|
84
|
+
writeModule(join(homeDir, ".omp", "agent", "modules"), "alpha.py", "user-omp");
|
|
85
|
+
writeModule(join(cwd, ".omp", "modules"), "beta.py", "project-omp");
|
|
86
|
+
|
|
87
|
+
const executor: PythonModuleExecutor = {
|
|
88
|
+
execute: async (code: string) => {
|
|
89
|
+
if (code.includes("def beta")) {
|
|
90
|
+
return {
|
|
91
|
+
status: "error",
|
|
92
|
+
cancelled: false,
|
|
93
|
+
error: { name: "Error", value: "boom", traceback: [] },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { status: "ok", cancelled: false };
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
await expect(loadPythonModules(executor, { cwd, homeDir })).rejects.toThrow("Failed to load Python module");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type PythonModuleSource = "user" | "project";
|
|
6
|
+
|
|
7
|
+
export interface PythonModuleEntry {
|
|
8
|
+
path: string;
|
|
9
|
+
content: string;
|
|
10
|
+
source: PythonModuleSource;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PythonModuleExecuteResult {
|
|
14
|
+
status: "ok" | "error";
|
|
15
|
+
cancelled: boolean;
|
|
16
|
+
error?: { name: string; value: string; traceback: string[] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PythonModuleExecutor {
|
|
20
|
+
execute: (
|
|
21
|
+
code: string,
|
|
22
|
+
options?: { silent?: boolean; storeHistory?: boolean },
|
|
23
|
+
) => Promise<PythonModuleExecuteResult>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DiscoverPythonModulesOptions {
|
|
27
|
+
/** Working directory for project-level modules. Default: process.cwd() */
|
|
28
|
+
cwd?: string;
|
|
29
|
+
/** Home directory for user-level modules. Default: os.homedir() */
|
|
30
|
+
homeDir?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ModuleCandidate {
|
|
34
|
+
name: string;
|
|
35
|
+
path: string;
|
|
36
|
+
source: PythonModuleSource;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function listModuleCandidates(dir: string, source: PythonModuleSource): Promise<ModuleCandidate[]> {
|
|
40
|
+
try {
|
|
41
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
42
|
+
return entries
|
|
43
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".py"))
|
|
44
|
+
.map((entry) => ({
|
|
45
|
+
name: entry.name,
|
|
46
|
+
path: resolve(dir, entry.name),
|
|
47
|
+
source,
|
|
48
|
+
}));
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readModuleContent(candidate: ModuleCandidate): Promise<PythonModuleEntry> {
|
|
55
|
+
try {
|
|
56
|
+
const content = await Bun.file(candidate.path).text();
|
|
57
|
+
return { path: candidate.path, content, source: candidate.source };
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
60
|
+
throw new Error(`Failed to read Python module ${candidate.path}: ${message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Discover Python prelude extension modules from user and project directories.
|
|
66
|
+
*/
|
|
67
|
+
export async function discoverPythonModules(options: DiscoverPythonModulesOptions = {}): Promise<PythonModuleEntry[]> {
|
|
68
|
+
const cwd = options.cwd ?? process.cwd();
|
|
69
|
+
const homeDir = options.homeDir ?? homedir();
|
|
70
|
+
|
|
71
|
+
const userDirs = [join(homeDir, ".omp", "agent", "modules"), join(homeDir, ".pi", "agent", "modules")];
|
|
72
|
+
const projectDirs = [resolve(cwd, ".omp", "modules"), resolve(cwd, ".pi", "modules")];
|
|
73
|
+
|
|
74
|
+
const userCandidates = (await Promise.all(userDirs.map((dir) => listModuleCandidates(dir, "user")))).flat();
|
|
75
|
+
const projectCandidates = (await Promise.all(projectDirs.map((dir) => listModuleCandidates(dir, "project")))).flat();
|
|
76
|
+
|
|
77
|
+
const byName = new Map<string, ModuleCandidate>();
|
|
78
|
+
for (const candidate of userCandidates) {
|
|
79
|
+
if (!byName.has(candidate.name)) {
|
|
80
|
+
byName.set(candidate.name, candidate);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const candidate of projectCandidates) {
|
|
84
|
+
const existing = byName.get(candidate.name);
|
|
85
|
+
if (!existing || existing.source === "user") {
|
|
86
|
+
byName.set(candidate.name, candidate);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sorted = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
91
|
+
return Promise.all(sorted.map((candidate) => readModuleContent(candidate)));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Load Python prelude extension modules into an active kernel.
|
|
96
|
+
*/
|
|
97
|
+
export async function loadPythonModules(
|
|
98
|
+
executor: PythonModuleExecutor,
|
|
99
|
+
options: DiscoverPythonModulesOptions = {},
|
|
100
|
+
): Promise<PythonModuleEntry[]> {
|
|
101
|
+
const modules = await discoverPythonModules(options);
|
|
102
|
+
for (const module of modules) {
|
|
103
|
+
const result = await executor.execute(module.content, { silent: true, storeHistory: false });
|
|
104
|
+
if (result.cancelled || result.status === "error") {
|
|
105
|
+
const details = result.error ? `${result.error.name}: ${result.error.value}` : "unknown error";
|
|
106
|
+
throw new Error(`Failed to load Python module ${module.path}: ${details}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return modules;
|
|
110
|
+
}
|