@oh-my-pi/pi-coding-agent 16.1.13 → 16.1.14
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 +27 -0
- package/dist/cli.js +5547 -2759
- package/dist/types/config/settings-schema.d.ts +44 -14
- package/dist/types/eval/__tests__/julia-prelude.test.d.ts +1 -0
- package/dist/types/eval/backend-helpers.d.ts +23 -0
- package/dist/types/eval/executor-base.d.ts +118 -0
- package/dist/types/eval/index.d.ts +2 -0
- package/dist/types/eval/jl/executor.d.ts +44 -0
- package/dist/types/eval/jl/index.d.ts +11 -0
- package/dist/types/eval/jl/kernel.d.ts +28 -0
- package/dist/types/eval/jl/prelude.d.ts +1 -0
- package/dist/types/eval/jl/runtime.d.ts +22 -0
- package/dist/types/eval/kernel-base.d.ts +105 -0
- package/dist/types/eval/py/kernel.d.ts +3 -61
- package/dist/types/eval/rb/executor.d.ts +77 -0
- package/dist/types/eval/rb/index.d.ts +11 -0
- package/dist/types/eval/rb/kernel.d.ts +31 -0
- package/dist/types/eval/rb/prelude.d.ts +1 -0
- package/dist/types/eval/rb/runtime.d.ts +23 -0
- package/dist/types/eval/runtime-env.d.ts +24 -0
- package/dist/types/eval/types.d.ts +3 -3
- package/dist/types/extensibility/extensions/runner.d.ts +2 -15
- package/dist/types/extensibility/hooks/loader.d.ts +1 -25
- package/dist/types/extensibility/session-handler-types.d.ts +23 -0
- package/dist/types/jsonrpc/message-framing.d.ts +35 -0
- package/dist/types/mnemopi/embed-client.d.ts +7 -24
- package/dist/types/modes/components/chat-transcript-builder.d.ts +1 -1
- package/dist/types/modes/components/custom-editor.d.ts +11 -0
- package/dist/types/modes/components/selector-helpers.d.ts +53 -0
- package/dist/types/modes/interactive-mode.d.ts +0 -2
- package/dist/types/modes/theme/theme.d.ts +8 -1
- package/dist/types/modes/types.d.ts +0 -2
- package/dist/types/modes/utils/copy-targets.d.ts +1 -1
- package/dist/types/modes/utils/interactive-context-helpers.d.ts +14 -0
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +54 -0
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +2 -2
- package/dist/types/stt/asr-client.d.ts +3 -29
- package/dist/types/subprocess/worker-client.d.ts +149 -0
- package/dist/types/subprocess/worker-runtime.d.ts +107 -0
- package/dist/types/tiny/title-client.d.ts +14 -34
- package/dist/types/tools/eval-backends.d.ts +6 -3
- package/dist/types/tools/eval-render.d.ts +6 -5
- package/dist/types/tools/eval.d.ts +13 -15
- package/dist/types/tools/index.d.ts +3 -3
- package/dist/types/tts/tts-client.d.ts +3 -28
- package/dist/types/tui/code-cell.d.ts +7 -0
- package/dist/types/utils/file-display-mode.d.ts +1 -1
- package/dist/types/web/parallel.d.ts +6 -0
- package/package.json +12 -12
- package/src/config/settings-schema.ts +50 -18
- package/src/config/settings.ts +5 -0
- package/src/dap/client.ts +13 -107
- package/src/eval/__tests__/julia-prelude.test.ts +77 -0
- package/src/eval/backend-helpers.ts +48 -0
- package/src/eval/executor-base.ts +425 -0
- package/src/eval/index.ts +2 -0
- package/src/eval/jl/executor.ts +540 -0
- package/src/eval/jl/index.ts +54 -0
- package/src/eval/jl/kernel.ts +235 -0
- package/src/eval/jl/prelude.jl +930 -0
- package/src/eval/jl/prelude.ts +3 -0
- package/src/eval/jl/runner.jl +634 -0
- package/src/eval/jl/runtime.ts +118 -0
- package/src/eval/js/index.ts +3 -14
- package/src/eval/kernel-base.ts +569 -0
- package/src/eval/py/executor.ts +43 -252
- package/src/eval/py/index.ts +9 -20
- package/src/eval/py/kernel.ts +29 -544
- package/src/eval/rb/executor.ts +504 -0
- package/src/eval/rb/index.ts +54 -0
- package/src/eval/rb/kernel.ts +230 -0
- package/src/eval/rb/prelude.rb +721 -0
- package/src/eval/rb/prelude.ts +3 -0
- package/src/eval/rb/runner.rb +474 -0
- package/src/eval/rb/runtime.ts +132 -0
- package/src/eval/runtime-env.ts +104 -0
- package/src/eval/types.ts +3 -3
- package/src/extensibility/extensions/runner.ts +4 -11
- package/src/extensibility/hooks/loader.ts +3 -21
- package/src/extensibility/session-handler-types.ts +21 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/jsonrpc/message-framing.ts +142 -0
- package/src/lsp/client.ts +13 -109
- package/src/mnemopi/embed-client.ts +43 -198
- package/src/modes/components/agent-dashboard.ts +17 -40
- package/src/modes/components/chat-transcript-builder.ts +18 -102
- package/src/modes/components/custom-editor.ts +19 -0
- package/src/modes/components/extensions/extension-dashboard.ts +4 -13
- package/src/modes/components/extensions/extension-list.ts +16 -44
- package/src/modes/components/history-search.ts +4 -16
- package/src/modes/components/selector-helpers.ts +129 -0
- package/src/modes/components/settings-selector.ts +7 -5
- package/src/modes/components/tree-selector.ts +13 -18
- package/src/modes/controllers/event-controller.ts +3 -9
- package/src/modes/controllers/input-controller.ts +32 -54
- package/src/modes/interactive-mode.ts +5 -7
- package/src/modes/theme/theme.ts +35 -0
- package/src/modes/types.ts +0 -2
- package/src/modes/utils/copy-targets.ts +3 -2
- package/src/modes/utils/interactive-context-helpers.ts +27 -0
- package/src/modes/utils/transcript-render-helpers.ts +157 -0
- package/src/modes/utils/ui-helpers.ts +21 -126
- package/src/prompts/system/system-prompt.md +10 -10
- package/src/prompts/tools/bash.md +0 -1
- package/src/prompts/tools/eval.md +6 -4
- package/src/prompts/tools/find.md +0 -4
- package/src/prompts/tools/read.md +1 -2
- package/src/prompts/tools/replace.md +1 -1
- package/src/prompts/tools/search.md +0 -1
- package/src/sdk.ts +13 -7
- package/src/session/agent-session.ts +9 -7
- package/src/stt/asr-client.ts +35 -215
- package/src/stt/asr-worker.ts +29 -181
- package/src/subprocess/worker-client.ts +297 -0
- package/src/subprocess/worker-runtime.ts +277 -0
- package/src/task/executor.ts +4 -4
- package/src/tiny/title-client.ts +53 -219
- package/src/tiny/worker.ts +29 -180
- package/src/tools/eval-backends.ts +10 -3
- package/src/tools/eval-render.ts +17 -8
- package/src/tools/eval.ts +187 -22
- package/src/tools/index.ts +51 -28
- package/src/tts/tts-client.ts +38 -206
- package/src/tts/tts-worker.ts +23 -97
- package/src/tui/code-cell.ts +12 -1
- package/src/utils/file-display-mode.ts +2 -3
- package/src/web/parallel.ts +43 -42
- package/src/web/search/providers/parallel.ts +10 -99
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Content-Length message framing for the JSON byte streams spoken by the
|
|
3
|
+
* LSP and DAP stdio clients. Both protocols use the same base-protocol framing:
|
|
4
|
+
* each message is a `Content-Length: <n>\r\n\r\n` header block followed by `<n>`
|
|
5
|
+
* bytes of UTF-8 JSON. This module owns the incremental decode so the two
|
|
6
|
+
* clients don't each reimplement chunk accumulation, header scanning, and the
|
|
7
|
+
* mid-message remainder handoff.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Reused for all full (non-streaming) decodes; each decode() resets state, so a
|
|
11
|
+
// single instance is safe and avoids per-message TextDecoder allocation.
|
|
12
|
+
const MESSAGE_DECODER = new TextDecoder("utf-8");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Locate the `\r\n\r\n` header terminator across the pending chunk list.
|
|
16
|
+
* Returns the absolute byte index of the first `\r`, or -1 when not present.
|
|
17
|
+
* Equivalent to scanning the contiguous concatenation of the chunks.
|
|
18
|
+
*/
|
|
19
|
+
function findHeaderEndInChunks(chunks: Buffer[]): number {
|
|
20
|
+
let global = 0;
|
|
21
|
+
let b0 = -1;
|
|
22
|
+
let b1 = -1;
|
|
23
|
+
let b2 = -1;
|
|
24
|
+
for (const chunk of chunks) {
|
|
25
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
26
|
+
const b3 = chunk[i];
|
|
27
|
+
if (b0 === 13 && b1 === 10 && b2 === 13 && b3 === 10) {
|
|
28
|
+
return global - 3;
|
|
29
|
+
}
|
|
30
|
+
b0 = b1;
|
|
31
|
+
b1 = b2;
|
|
32
|
+
b2 = b3;
|
|
33
|
+
global++;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return -1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Copy the byte range [from, to) out of the pending chunk list into one Buffer. */
|
|
40
|
+
function copyChunkRange(chunks: Buffer[], from: number, to: number): Buffer {
|
|
41
|
+
const out = Buffer.allocUnsafe(to - from);
|
|
42
|
+
let global = 0;
|
|
43
|
+
let written = 0;
|
|
44
|
+
for (const chunk of chunks) {
|
|
45
|
+
const chunkEnd = global + chunk.length;
|
|
46
|
+
if (chunkEnd > from && global < to) {
|
|
47
|
+
const start = Math.max(from, global) - global;
|
|
48
|
+
const end = Math.min(to, chunkEnd) - global;
|
|
49
|
+
chunk.copy(out, written, start, end);
|
|
50
|
+
written += end - start;
|
|
51
|
+
}
|
|
52
|
+
global = chunkEnd;
|
|
53
|
+
if (global >= to) break;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Drop the first `count` bytes from the pending chunk list in place. */
|
|
59
|
+
function dropChunkFront(chunks: Buffer[], count: number): void {
|
|
60
|
+
let removed = 0;
|
|
61
|
+
while (chunks.length > 0) {
|
|
62
|
+
const head = chunks[0];
|
|
63
|
+
if (removed + head.length <= count) {
|
|
64
|
+
removed += head.length;
|
|
65
|
+
chunks.shift();
|
|
66
|
+
} else {
|
|
67
|
+
chunks[0] = head.subarray(count - removed);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Incremental Content-Length frame decoder for a JSON message byte stream.
|
|
75
|
+
*
|
|
76
|
+
* Incoming bytes are buffered as a list of chunks and only joined when a full
|
|
77
|
+
* message is framed — concatenating the accumulator on every read is O(n^2) for
|
|
78
|
+
* messages that span many reads (e.g. a large initial diagnostics burst). Feed
|
|
79
|
+
* raw chunks with {@link push}, pull every complete message with {@link drain},
|
|
80
|
+
* and persist {@link remainder} when the reader stops so a restarted reader
|
|
81
|
+
* resumes mid-message.
|
|
82
|
+
*/
|
|
83
|
+
export class MessageFramer {
|
|
84
|
+
readonly #pendingChunks: Buffer[] = [];
|
|
85
|
+
#pendingLen = 0;
|
|
86
|
+
|
|
87
|
+
/** Seed the buffer with any unparsed remainder left by a previous reader. */
|
|
88
|
+
constructor(seed: Buffer) {
|
|
89
|
+
if (seed.length > 0) {
|
|
90
|
+
this.#pendingChunks.push(seed);
|
|
91
|
+
this.#pendingLen = seed.length;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Append a freshly read chunk to the pending buffer. */
|
|
96
|
+
push(chunk: Buffer): void {
|
|
97
|
+
this.#pendingChunks.push(chunk);
|
|
98
|
+
this.#pendingLen += chunk.length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Yield the JSON text of every complete message currently buffered. A header
|
|
103
|
+
* block without a `Content-Length` is non-protocol noise (e.g. a server
|
|
104
|
+
* printing to stdout); `onResync` is invoked with the offending header text
|
|
105
|
+
* and the framer drops past the bogus terminator to recover instead of
|
|
106
|
+
* stalling on the same junk header forever.
|
|
107
|
+
*/
|
|
108
|
+
*drain(onResync: (headerText: string) => void): Generator<string> {
|
|
109
|
+
while (true) {
|
|
110
|
+
const headerEnd = findHeaderEndInChunks(this.#pendingChunks);
|
|
111
|
+
if (headerEnd === -1) break;
|
|
112
|
+
|
|
113
|
+
const headerText = MESSAGE_DECODER.decode(copyChunkRange(this.#pendingChunks, 0, headerEnd));
|
|
114
|
+
const contentLengthMatch = headerText.match(/Content-Length: (\d+)/i);
|
|
115
|
+
if (!contentLengthMatch) {
|
|
116
|
+
onResync(headerText);
|
|
117
|
+
dropChunkFront(this.#pendingChunks, headerEnd + 4);
|
|
118
|
+
this.#pendingLen -= headerEnd + 4;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
|
|
123
|
+
const messageStart = headerEnd + 4; // Skip \r\n\r\n
|
|
124
|
+
const messageEnd = messageStart + contentLength;
|
|
125
|
+
if (this.#pendingLen < messageEnd) break;
|
|
126
|
+
|
|
127
|
+
const messageText = MESSAGE_DECODER.decode(copyChunkRange(this.#pendingChunks, messageStart, messageEnd));
|
|
128
|
+
dropChunkFront(this.#pendingChunks, messageEnd);
|
|
129
|
+
this.#pendingLen -= messageEnd;
|
|
130
|
+
yield messageText;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The unparsed remainder, to persist when the reader stops. */
|
|
135
|
+
remainder(): Buffer {
|
|
136
|
+
return this.#pendingChunks.length === 0
|
|
137
|
+
? Buffer.alloc(0)
|
|
138
|
+
: this.#pendingChunks.length === 1
|
|
139
|
+
? this.#pendingChunks[0]
|
|
140
|
+
: Buffer.concat(this.#pendingChunks, this.#pendingLen);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/lsp/client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { isEnoent, logger, ptree, untilAborted } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import { MessageFramer } from "../jsonrpc/message-framing";
|
|
3
4
|
import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
|
|
4
5
|
import { applyWorkspaceEdit } from "./edits";
|
|
5
6
|
import { getLspmuxCommand, isLspmuxSupported } from "./lspmux";
|
|
@@ -179,69 +180,6 @@ const CLIENT_CAPABILITIES = {
|
|
|
179
180
|
// LSP Message Protocol
|
|
180
181
|
// =============================================================================
|
|
181
182
|
|
|
182
|
-
// Reused for all full (non-streaming) decodes; each decode() resets state, so a
|
|
183
|
-
// single instance is safe and avoids per-message TextDecoder allocation.
|
|
184
|
-
const MESSAGE_DECODER = new TextDecoder("utf-8");
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Locate the `\r\n\r\n` header terminator across the pending chunk list.
|
|
188
|
-
* Returns the absolute byte index of the first `\r`, or -1 when not present.
|
|
189
|
-
* Equivalent to scanning the contiguous concatenation of the chunks.
|
|
190
|
-
*/
|
|
191
|
-
function findHeaderEndInChunks(chunks: Buffer[]): number {
|
|
192
|
-
let global = 0;
|
|
193
|
-
let b0 = -1;
|
|
194
|
-
let b1 = -1;
|
|
195
|
-
let b2 = -1;
|
|
196
|
-
for (const chunk of chunks) {
|
|
197
|
-
for (let i = 0; i < chunk.length; i++) {
|
|
198
|
-
const b3 = chunk[i];
|
|
199
|
-
if (b0 === 13 && b1 === 10 && b2 === 13 && b3 === 10) {
|
|
200
|
-
return global - 3;
|
|
201
|
-
}
|
|
202
|
-
b0 = b1;
|
|
203
|
-
b1 = b2;
|
|
204
|
-
b2 = b3;
|
|
205
|
-
global++;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return -1;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** Copy the byte range [from, to) out of the pending chunk list into one Buffer. */
|
|
212
|
-
function copyChunkRange(chunks: Buffer[], from: number, to: number): Buffer {
|
|
213
|
-
const out = Buffer.allocUnsafe(to - from);
|
|
214
|
-
let global = 0;
|
|
215
|
-
let written = 0;
|
|
216
|
-
for (const chunk of chunks) {
|
|
217
|
-
const chunkEnd = global + chunk.length;
|
|
218
|
-
if (chunkEnd > from && global < to) {
|
|
219
|
-
const start = Math.max(from, global) - global;
|
|
220
|
-
const end = Math.min(to, chunkEnd) - global;
|
|
221
|
-
chunk.copy(out, written, start, end);
|
|
222
|
-
written += end - start;
|
|
223
|
-
}
|
|
224
|
-
global = chunkEnd;
|
|
225
|
-
if (global >= to) break;
|
|
226
|
-
}
|
|
227
|
-
return out;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Drop the first `count` bytes from the pending chunk list in place. */
|
|
231
|
-
function dropChunkFront(chunks: Buffer[], count: number): void {
|
|
232
|
-
let removed = 0;
|
|
233
|
-
while (chunks.length > 0) {
|
|
234
|
-
const head = chunks[0];
|
|
235
|
-
if (removed + head.length <= count) {
|
|
236
|
-
removed += head.length;
|
|
237
|
-
chunks.shift();
|
|
238
|
-
} else {
|
|
239
|
-
chunks[0] = head.subarray(count - removed);
|
|
240
|
-
break;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
183
|
async function writeMessage(
|
|
246
184
|
sink: Bun.FileSink,
|
|
247
185
|
message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse,
|
|
@@ -274,54 +212,25 @@ async function startMessageReader(client: LspClient): Promise<void> {
|
|
|
274
212
|
|
|
275
213
|
const reader = (client.proc.stdout as ReadableStream<Uint8Array>).getReader();
|
|
276
214
|
|
|
277
|
-
|
|
278
|
-
// message is framed. Concatenating the accumulator on every read was O(n^2)
|
|
279
|
-
// for messages that span many reads (e.g. a large initial diagnostics burst).
|
|
280
|
-
const pendingChunks: Buffer[] = [];
|
|
281
|
-
let pendingLen = 0;
|
|
282
|
-
if (client.messageBuffer.length > 0) {
|
|
283
|
-
const seed = Buffer.from(client.messageBuffer);
|
|
284
|
-
pendingChunks.push(seed);
|
|
285
|
-
pendingLen = seed.length;
|
|
286
|
-
}
|
|
215
|
+
const framer = new MessageFramer(Buffer.from(client.messageBuffer));
|
|
287
216
|
|
|
288
217
|
try {
|
|
289
218
|
while (true) {
|
|
290
219
|
const { done, value } = await reader.read();
|
|
291
220
|
if (done) break;
|
|
292
221
|
|
|
293
|
-
|
|
294
|
-
pendingLen += value.length;
|
|
222
|
+
framer.push(Buffer.from(value));
|
|
295
223
|
|
|
296
224
|
// Drain every complete message currently buffered.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
// on the same junk header forever.
|
|
307
|
-
logger.warn("LSP framing resync: header block without Content-Length", {
|
|
308
|
-
server: client.name,
|
|
309
|
-
header: headerText.slice(0, 200),
|
|
310
|
-
});
|
|
311
|
-
dropChunkFront(pendingChunks, headerEnd + 4);
|
|
312
|
-
pendingLen -= headerEnd + 4;
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
|
|
317
|
-
const messageStart = headerEnd + 4; // Skip \r\n\r\n
|
|
318
|
-
const messageEnd = messageStart + contentLength;
|
|
319
|
-
if (pendingLen < messageEnd) break;
|
|
320
|
-
|
|
321
|
-
const messageText = MESSAGE_DECODER.decode(copyChunkRange(pendingChunks, messageStart, messageEnd));
|
|
322
|
-
dropChunkFront(pendingChunks, messageEnd);
|
|
323
|
-
pendingLen -= messageEnd;
|
|
324
|
-
|
|
225
|
+
for (const messageText of framer.drain(headerText => {
|
|
226
|
+
// Non-protocol bytes on stdout (e.g. a wrapper script printing).
|
|
227
|
+
// Drop past the bogus terminator and resync instead of stalling
|
|
228
|
+
// on the same junk header forever.
|
|
229
|
+
logger.warn("LSP framing resync: header block without Content-Length", {
|
|
230
|
+
server: client.name,
|
|
231
|
+
header: headerText.slice(0, 200),
|
|
232
|
+
});
|
|
233
|
+
})) {
|
|
325
234
|
// A malformed message or a throwing server-request handler must not
|
|
326
235
|
// kill the reader — later messages are still well-framed.
|
|
327
236
|
try {
|
|
@@ -392,12 +301,7 @@ async function startMessageReader(client: LspClient): Promise<void> {
|
|
|
392
301
|
client.pendingRequests.clear();
|
|
393
302
|
} finally {
|
|
394
303
|
// Persist any unparsed remainder so a restarted reader resumes mid-message.
|
|
395
|
-
client.messageBuffer =
|
|
396
|
-
pendingChunks.length === 0
|
|
397
|
-
? new Uint8Array(0)
|
|
398
|
-
: pendingChunks.length === 1
|
|
399
|
-
? pendingChunks[0]
|
|
400
|
-
: Buffer.concat(pendingChunks, pendingLen);
|
|
304
|
+
client.messageBuffer = framer.remainder();
|
|
401
305
|
reader.releaseLock();
|
|
402
306
|
client.isReading = false;
|
|
403
307
|
// Reader exited while the server process is still alive (unrecoverable
|
|
@@ -1,208 +1,76 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import {
|
|
3
|
+
createUnavailableWorker,
|
|
4
|
+
createWorkerHandle,
|
|
5
|
+
createWorkerSubprocess,
|
|
6
|
+
logWorkerMessage,
|
|
7
|
+
resolveWorkerSpawnCmd,
|
|
8
|
+
SMOKE_TEST_TIMEOUT_MS,
|
|
9
|
+
type SpawnedSubprocess,
|
|
10
|
+
smokeTestWorker,
|
|
11
|
+
spawnWorkerOrUnavailable,
|
|
12
|
+
type WorkerHandle,
|
|
13
|
+
workerEnvFromParent,
|
|
14
|
+
} from "../subprocess/worker-client";
|
|
4
15
|
import type { MnemopiEmbedModelId, MnemopiEmbedWorkerInbound, MnemopiEmbedWorkerOutbound } from "./embed-protocol";
|
|
5
16
|
|
|
6
17
|
/**
|
|
7
|
-
*
|
|
18
|
+
* Parent-side handle for the mnemopi embeddings subprocess. The runtime
|
|
8
19
|
* implementation is a Bun child process so `onnxruntime-node`'s NAPI
|
|
9
20
|
* constructor + finalizer never run inside the main agent address space —
|
|
10
21
|
* those destructors segfault Bun on Windows when mnemopi's local embedding
|
|
11
22
|
* provider loads fastembed in the main process (issue #3031; the mnemopi
|
|
12
23
|
* sibling of the tiny-model fix from #1606 / #1607).
|
|
13
24
|
*/
|
|
14
|
-
export
|
|
15
|
-
send(message: MnemopiEmbedWorkerInbound): void;
|
|
16
|
-
onMessage(handler: (message: MnemopiEmbedWorkerOutbound) => void): () => void;
|
|
17
|
-
onError(handler: (error: Error) => void): () => void;
|
|
18
|
-
terminate(): Promise<void>;
|
|
19
|
-
}
|
|
25
|
+
export type MnemopiEmbedWorkerHandle = WorkerHandle<MnemopiEmbedWorkerInbound, MnemopiEmbedWorkerOutbound>;
|
|
20
26
|
|
|
21
27
|
type PendingRequest =
|
|
22
28
|
| { kind: "init"; model: MnemopiEmbedModelId; resolve: (ok: boolean) => void }
|
|
23
29
|
| { kind: "embed"; model: MnemopiEmbedModelId; resolve: (vectors: number[][] | Error) => void };
|
|
24
30
|
|
|
25
|
-
// Cold-starting the worker from a compiled binary (decompress + module graph load)
|
|
26
|
-
// is slow on contended CI runners; the probe only proves the worker spawns and
|
|
27
|
-
// ponges, so a generous bound removes flakes without weakening the check.
|
|
28
|
-
const SMOKE_TEST_TIMEOUT_MS = 30_000;
|
|
29
|
-
|
|
30
31
|
/**
|
|
31
32
|
* Hidden subcommand on the main CLI that boots the mnemopi embeddings worker
|
|
32
33
|
* in the spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
|
|
33
34
|
*/
|
|
34
35
|
export const MNEMOPI_EMBED_WORKER_ARG = "__omp_worker_mnemopi_embed";
|
|
35
36
|
|
|
36
|
-
/**
|
|
37
|
-
* Env handed to the embeddings subprocess. The child inherits the parent's
|
|
38
|
-
* environment verbatim — fastembed honours `HF_HUB_*`, `HTTPS_PROXY`, etc.,
|
|
39
|
-
* and our `loadFastembed()` reads the same `OMP_*` runtime-install knobs the
|
|
40
|
-
* parent uses. `process.env` carries `undefined` slots that Bun.spawn rejects;
|
|
41
|
-
* filter them out.
|
|
42
|
-
*/
|
|
43
|
-
function mnemopiEmbedWorkerEnv(): Record<string, string> {
|
|
44
|
-
const base = $env as Record<string, string | undefined>;
|
|
45
|
-
const merged: Record<string, string> = {};
|
|
46
|
-
for (const key in base) {
|
|
47
|
-
const value = base[key];
|
|
48
|
-
if (typeof value === "string") merged[key] = value;
|
|
49
|
-
}
|
|
50
|
-
return merged;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
interface MnemopiEmbedWorkerSpawnCommand {
|
|
54
|
-
cmd: string[];
|
|
55
|
-
cwd?: string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Resolve the command used to relaunch the agent CLI into mnemopi-embed-worker
|
|
60
|
-
* mode. In a compiled binary the entry point is the binary itself; otherwise
|
|
61
|
-
* re-enter the declared worker-host entry (cwd-relative for reliable Bun IPC),
|
|
62
|
-
* falling back to this package's own `src/cli.ts` when no host entry is
|
|
63
|
-
* declared (bun test, SDK embedding).
|
|
64
|
-
*/
|
|
65
|
-
function mnemopiEmbedWorkerSpawnCmd(): MnemopiEmbedWorkerSpawnCommand {
|
|
66
|
-
if (isCompiledBinary()) return { cmd: [process.execPath, MNEMOPI_EMBED_WORKER_ARG] };
|
|
67
|
-
const hostEntry = workerHostEntry();
|
|
68
|
-
if (hostEntry) {
|
|
69
|
-
return {
|
|
70
|
-
cmd: [process.execPath, path.basename(hostEntry), MNEMOPI_EMBED_WORKER_ARG],
|
|
71
|
-
cwd: path.dirname(hostEntry),
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
const packageRoot = path.resolve(import.meta.dir, "..", "..");
|
|
75
|
-
return { cmd: [process.execPath, "src/cli.ts", MNEMOPI_EMBED_WORKER_ARG], cwd: packageRoot };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
interface SpawnedSubprocess {
|
|
79
|
-
proc: Subprocess<"ignore", "ignore", "ignore">;
|
|
80
|
-
inbound: Set<(message: MnemopiEmbedWorkerOutbound) => void>;
|
|
81
|
-
errors: Set<(error: Error) => void>;
|
|
82
|
-
/**
|
|
83
|
-
* Flipped to `true` right before the deliberate SIGKILL so `onExit` can
|
|
84
|
-
* distinguish the expected hard-kill from a crash (SIGSEGV from a native
|
|
85
|
-
* fault, OOM SIGKILL, operator `kill -9`). Only the latter surfaces as a
|
|
86
|
-
* worker error so callers don't await forever.
|
|
87
|
-
*/
|
|
88
|
-
intentionalExit: { value: boolean };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
37
|
/**
|
|
92
38
|
* Spawn the mnemopi embeddings worker as a subprocess. Exported for tests and
|
|
93
39
|
* the smoke probe; production callers go through {@link spawnMnemopiEmbedWorker}.
|
|
40
|
+
* The child inherits the parent env verbatim — fastembed honours `HF_HUB_*`,
|
|
41
|
+
* `HTTPS_PROXY`, etc., and our `loadFastembed()` reads the same `OMP_*`
|
|
42
|
+
* runtime-install knobs the parent uses.
|
|
94
43
|
*/
|
|
95
|
-
export function createMnemopiEmbedSubprocess(): SpawnedSubprocess {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const proc = Bun.spawn({
|
|
101
|
-
cmd: spawnCommand.cmd,
|
|
102
|
-
cwd: spawnCommand.cwd,
|
|
103
|
-
env: mnemopiEmbedWorkerEnv(),
|
|
104
|
-
stdin: "ignore",
|
|
105
|
-
stdout: "ignore",
|
|
106
|
-
stderr: "ignore",
|
|
107
|
-
serialization: "advanced",
|
|
108
|
-
windowsHide: true,
|
|
109
|
-
ipc(message) {
|
|
110
|
-
for (const handler of inbound) handler(message as MnemopiEmbedWorkerOutbound);
|
|
111
|
-
},
|
|
112
|
-
onExit(_proc, exitCode, signalCode) {
|
|
113
|
-
if (exitCode === 0) return;
|
|
114
|
-
if (exitCode === null && intentionalExit.value) return;
|
|
115
|
-
const reason = exitCode !== null ? `code ${exitCode}` : `signal ${signalCode ?? "unknown"}`;
|
|
116
|
-
const err = new Error(`mnemopi embed subprocess exited with ${reason}`);
|
|
117
|
-
for (const handler of errors) handler(err);
|
|
118
|
-
},
|
|
44
|
+
export function createMnemopiEmbedSubprocess(): SpawnedSubprocess<MnemopiEmbedWorkerOutbound> {
|
|
45
|
+
return createWorkerSubprocess<MnemopiEmbedWorkerOutbound>({
|
|
46
|
+
spawnCommand: resolveWorkerSpawnCmd(MNEMOPI_EMBED_WORKER_ARG),
|
|
47
|
+
env: workerEnvFromParent(),
|
|
48
|
+
exitLabel: "mnemopi embed subprocess",
|
|
119
49
|
});
|
|
120
|
-
// Don't keep the parent event loop alive on an idle worker; the agent
|
|
121
|
-
// dispose path calls `terminate()` explicitly. Bun's test runner starves
|
|
122
|
-
// IPC for unref'd subprocesses, so keep it referenced only under tests.
|
|
123
|
-
if (!isBunTestRuntime()) proc.unref();
|
|
124
|
-
return { proc, inbound, errors, intentionalExit };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function wrapSubprocess({ proc, inbound, errors, intentionalExit }: SpawnedSubprocess): MnemopiEmbedWorkerHandle {
|
|
128
|
-
return {
|
|
129
|
-
send(message) {
|
|
130
|
-
try {
|
|
131
|
-
proc.send(message);
|
|
132
|
-
} catch (error) {
|
|
133
|
-
logger.debug("mnemopi-embed: send to subprocess failed", {
|
|
134
|
-
error: error instanceof Error ? error.message : String(error),
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
},
|
|
138
|
-
onMessage(handler) {
|
|
139
|
-
inbound.add(handler);
|
|
140
|
-
return () => inbound.delete(handler);
|
|
141
|
-
},
|
|
142
|
-
onError(handler) {
|
|
143
|
-
errors.add(handler);
|
|
144
|
-
return () => errors.delete(handler);
|
|
145
|
-
},
|
|
146
|
-
async terminate() {
|
|
147
|
-
// SIGKILL: the point of subprocess isolation is that the parent
|
|
148
|
-
// never runs `onnxruntime-node`'s NAPI finalizer (it crashes Bun
|
|
149
|
-
// on Windows). Hard-kill instead; the OS reclaims the model
|
|
150
|
-
// memory. Flip the intentional-exit flag *before* killing so
|
|
151
|
-
// `onExit` can tell this apart from a native crash.
|
|
152
|
-
intentionalExit.value = true;
|
|
153
|
-
try {
|
|
154
|
-
proc.kill("SIGKILL");
|
|
155
|
-
} catch {
|
|
156
|
-
// Already gone.
|
|
157
|
-
}
|
|
158
|
-
},
|
|
159
|
-
};
|
|
160
50
|
}
|
|
161
51
|
|
|
162
|
-
function
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
emit({ type: "error", id: message.id, error: errorMessage });
|
|
52
|
+
function wrapSubprocess(spawned: SpawnedSubprocess<MnemopiEmbedWorkerOutbound>): MnemopiEmbedWorkerHandle {
|
|
53
|
+
const { proc } = spawned;
|
|
54
|
+
// Embed keeps its own guarded `proc.send` (neutralizes only the synchronous
|
|
55
|
+
// throw, not the async EPIPE rejection) rather than the shared `safeSend`
|
|
56
|
+
// the other workers use — behaviour preserved verbatim.
|
|
57
|
+
return createWorkerHandle<MnemopiEmbedWorkerInbound, MnemopiEmbedWorkerOutbound>(spawned, message => {
|
|
58
|
+
try {
|
|
59
|
+
proc.send(message);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
logger.debug("mnemopi-embed: send to subprocess failed", {
|
|
62
|
+
error: error instanceof Error ? error.message : String(error),
|
|
176
63
|
});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
listeners.add(handler);
|
|
180
|
-
return () => listeners.delete(handler);
|
|
181
|
-
},
|
|
182
|
-
onError() {
|
|
183
|
-
return () => {};
|
|
184
|
-
},
|
|
185
|
-
async terminate() {
|
|
186
|
-
listeners.clear();
|
|
187
|
-
},
|
|
188
|
-
};
|
|
64
|
+
}
|
|
65
|
+
});
|
|
189
66
|
}
|
|
190
67
|
|
|
191
68
|
function spawnMnemopiEmbedWorker(): MnemopiEmbedWorkerHandle {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
});
|
|
198
|
-
return spawnInlineUnavailableWorker(error);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function logWorkerMessage(message: Extract<MnemopiEmbedWorkerOutbound, { type: "log" }>): void {
|
|
203
|
-
if (message.level === "debug") logger.debug(message.msg, message.meta);
|
|
204
|
-
else if (message.level === "warn") logger.warn(message.msg, message.meta);
|
|
205
|
-
else logger.error(message.msg, message.meta);
|
|
69
|
+
return spawnWorkerOrUnavailable(
|
|
70
|
+
() => wrapSubprocess(createMnemopiEmbedSubprocess()),
|
|
71
|
+
createUnavailableWorker<MnemopiEmbedWorkerInbound, MnemopiEmbedWorkerOutbound>,
|
|
72
|
+
"mnemopi embed worker spawn failed; local embeddings disabled",
|
|
73
|
+
);
|
|
206
74
|
}
|
|
207
75
|
|
|
208
76
|
/**
|
|
@@ -374,28 +242,5 @@ export async function smokeTestMnemopiEmbedWorker({
|
|
|
374
242
|
}: {
|
|
375
243
|
timeoutMs?: number;
|
|
376
244
|
} = {}): Promise<void> {
|
|
377
|
-
|
|
378
|
-
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
379
|
-
const timer = setTimeout(
|
|
380
|
-
() => reject(new Error(`mnemopi embed worker did not pong within ${timeoutMs}ms`)),
|
|
381
|
-
timeoutMs,
|
|
382
|
-
);
|
|
383
|
-
const unsubscribeMessage = handle.onMessage(message => {
|
|
384
|
-
if (message.type === "pong") {
|
|
385
|
-
resolve();
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
if (message.type === "log") return;
|
|
389
|
-
reject(new Error(`mnemopi embed worker: expected pong, got ${JSON.stringify(message)}`));
|
|
390
|
-
});
|
|
391
|
-
const unsubscribeError = handle.onError(reject);
|
|
392
|
-
try {
|
|
393
|
-
handle.send({ type: "ping", id: "smoke" } satisfies MnemopiEmbedWorkerInbound);
|
|
394
|
-
await promise;
|
|
395
|
-
} finally {
|
|
396
|
-
clearTimeout(timer);
|
|
397
|
-
unsubscribeMessage();
|
|
398
|
-
unsubscribeError();
|
|
399
|
-
await handle.terminate();
|
|
400
|
-
}
|
|
245
|
+
await smokeTestWorker(wrapSubprocess(createMnemopiEmbedSubprocess()), "mnemopi embed worker", timeoutMs);
|
|
401
246
|
}
|
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
type Component,
|
|
22
22
|
Container,
|
|
23
23
|
Editor,
|
|
24
|
-
extractPrintableText,
|
|
25
24
|
fuzzyMatch,
|
|
26
25
|
Input,
|
|
27
26
|
matchesKey,
|
|
@@ -59,6 +58,7 @@ import {
|
|
|
59
58
|
matchesSelectUp,
|
|
60
59
|
} from "../utils/keybinding-matchers";
|
|
61
60
|
import { DynamicBorder } from "./dynamic-border";
|
|
61
|
+
import { clampSelection, handleTabSwitchKey, padLinesToHeight, searchableChar } from "./selector-helpers";
|
|
62
62
|
|
|
63
63
|
type SourceTabId = "all" | AgentSource;
|
|
64
64
|
type AgentScope = "project" | "user";
|
|
@@ -521,31 +521,19 @@ export class AgentDashboard extends Container {
|
|
|
521
521
|
const lines = super.render(width);
|
|
522
522
|
// Pad to the full viewport so every state (list, edit, create) covers the
|
|
523
523
|
// screen as a true full-screen view instead of letting the transcript peek
|
|
524
|
-
// through below it.
|
|
525
|
-
|
|
526
|
-
const rows = this.#terminalRows();
|
|
527
|
-
if (lines.length >= rows) return lines;
|
|
528
|
-
const padded = lines.slice();
|
|
529
|
-
while (padded.length < rows) padded.push("");
|
|
530
|
-
return padded;
|
|
524
|
+
// through below it.
|
|
525
|
+
return padLinesToHeight(lines, this.#terminalRows());
|
|
531
526
|
}
|
|
532
527
|
|
|
533
528
|
#clampSelection(): void {
|
|
534
|
-
|
|
535
|
-
this.#selectedIndex
|
|
536
|
-
this.#scrollOffset
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
this.#selectedIndex =
|
|
541
|
-
this.#
|
|
542
|
-
|
|
543
|
-
const maxVisible = this.#getMaxVisibleItems();
|
|
544
|
-
if (this.#selectedIndex < this.#scrollOffset) {
|
|
545
|
-
this.#scrollOffset = this.#selectedIndex;
|
|
546
|
-
} else if (this.#selectedIndex >= this.#scrollOffset + maxVisible) {
|
|
547
|
-
this.#scrollOffset = this.#selectedIndex - maxVisible + 1;
|
|
548
|
-
}
|
|
529
|
+
const next = clampSelection(
|
|
530
|
+
this.#selectedIndex,
|
|
531
|
+
this.#scrollOffset,
|
|
532
|
+
this.#filteredAgents.length,
|
|
533
|
+
this.#getMaxVisibleItems(),
|
|
534
|
+
);
|
|
535
|
+
this.#selectedIndex = next.selectedIndex;
|
|
536
|
+
this.#scrollOffset = next.scrollOffset;
|
|
549
537
|
}
|
|
550
538
|
|
|
551
539
|
#persistDisabledAgents(): void {
|
|
@@ -1150,12 +1138,7 @@ export class AgentDashboard extends Container {
|
|
|
1150
1138
|
return;
|
|
1151
1139
|
}
|
|
1152
1140
|
|
|
1153
|
-
if (
|
|
1154
|
-
this.#switchTab(1);
|
|
1155
|
-
return;
|
|
1156
|
-
}
|
|
1157
|
-
if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
|
|
1158
|
-
this.#switchTab(-1);
|
|
1141
|
+
if (handleTabSwitchKey(data, direction => this.#switchTab(direction))) {
|
|
1159
1142
|
return;
|
|
1160
1143
|
}
|
|
1161
1144
|
|
|
@@ -1190,17 +1173,11 @@ export class AgentDashboard extends Container {
|
|
|
1190
1173
|
return;
|
|
1191
1174
|
}
|
|
1192
1175
|
|
|
1193
|
-
const
|
|
1194
|
-
if (
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
this.#searchQuery += printableText;
|
|
1201
|
-
this.#applyFilters();
|
|
1202
|
-
this.#buildLayout();
|
|
1203
|
-
}
|
|
1176
|
+
const char = searchableChar(data);
|
|
1177
|
+
if (char !== null) {
|
|
1178
|
+
this.#searchQuery += char;
|
|
1179
|
+
this.#applyFilters();
|
|
1180
|
+
this.#buildLayout();
|
|
1204
1181
|
}
|
|
1205
1182
|
}
|
|
1206
1183
|
}
|