@nmzpy/pi-ember-stack 0.1.1 → 0.1.2
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/ATTRIBUTION.md +7 -2
- package/README.md +90 -69
- package/package.json +49 -50
- package/plugins/devin-auth/AGENTS.md +63 -0
- package/plugins/devin-auth/LICENSE +21 -0
- package/plugins/devin-auth/README.md +56 -0
- package/plugins/devin-auth/extensions/index.ts +141 -0
- package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -0
- package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -0
- package/plugins/devin-auth/src/cloud-direct/chat.ts +1091 -0
- package/plugins/devin-auth/src/cloud-direct/index.ts +41 -0
- package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -0
- package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -0
- package/plugins/devin-auth/src/context-map.ts +170 -0
- package/plugins/devin-auth/src/models.ts +236 -0
- package/plugins/devin-auth/src/oauth/login.ts +95 -0
- package/plugins/devin-auth/src/oauth/register-user.ts +174 -0
- package/plugins/devin-auth/src/oauth/types.ts +71 -0
- package/plugins/devin-auth/src/stream.ts +341 -0
- package/{src/pi-ember-stack.ts → plugins/ember/index.ts} +2 -2
- package/plugins/index.ts +138 -0
- package/{src → plugins}/subagent/extensions/model.ts +96 -96
- package/tsconfig.json +2 -1
- /package/{src → plugins/ember}/questionnaire-tool.ts +0 -0
- /package/{src → plugins}/subagent/LICENSE +0 -0
- /package/{src → plugins}/subagent/README.md +0 -0
- /package/{src → plugins}/subagent/agent-format.md +0 -0
- /package/{src → plugins}/subagent/agents/architect.md +0 -0
- /package/{src → plugins}/subagent/agents/coder.md +0 -0
- /package/{src → plugins}/subagent/agents/general-purpose.md +0 -0
- /package/{src → plugins}/subagent/agents/reviewer.md +0 -0
- /package/{src → plugins}/subagent/agents/scout.md +0 -0
- /package/{src → plugins}/subagent/agents/worker.md +0 -0
- /package/{src → plugins}/subagent/extensions/agents.ts +0 -0
- /package/{src → plugins}/subagent/extensions/index.ts +0 -0
- /package/{src → plugins}/subagent/extensions/package.json +0 -0
- /package/{src → plugins}/subagent/extensions/render.ts +0 -0
- /package/{src → plugins}/subagent/extensions/runner.ts +0 -0
- /package/{src → plugins}/subagent/extensions/service.ts +0 -0
- /package/{src → plugins}/subagent/extensions/thread-viewer.ts +0 -0
- /package/{src → plugins}/subagent/extensions/threads.ts +0 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi `streamSimple` implementation for the Devin/Cognition provider.
|
|
3
|
+
*
|
|
4
|
+
* Calls the cloud-direct gRPC streaming layer (`streamChatEvents`) and
|
|
5
|
+
* translates the resulting `CloudChatEvent` stream into pi's
|
|
6
|
+
* `AssistantMessageEventStream` events.
|
|
7
|
+
*
|
|
8
|
+
* The async-IIFE pattern is the standard pi `streamSimple` shape: the
|
|
9
|
+
* function returns a `AssistantMessageEventStream` synchronously and drives
|
|
10
|
+
* the upstream async iterator inside an IIFE, pushing events as they
|
|
11
|
+
* arrive.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
type Api,
|
|
15
|
+
type AssistantMessage,
|
|
16
|
+
type AssistantMessageEventStream,
|
|
17
|
+
type Context,
|
|
18
|
+
type Model,
|
|
19
|
+
type SimpleStreamOptions,
|
|
20
|
+
calculateCost,
|
|
21
|
+
createAssistantMessageEventStream,
|
|
22
|
+
} from '@earendil-works/pi-ai';
|
|
23
|
+
import { streamChatEvents, type CloudChatEvent } from './cloud-direct/index.js';
|
|
24
|
+
import { mapContextToChat } from './context-map.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Stream a Devin/Cognition chat completion into pi's assistant-message
|
|
28
|
+
* event stream.
|
|
29
|
+
*
|
|
30
|
+
* @param model pi model descriptor (id, api, provider, cost, ...).
|
|
31
|
+
* @param context pi conversation context (systemPrompt, messages, tools).
|
|
32
|
+
* @param options streaming options (apiKey, maxTokens, signal, ...).
|
|
33
|
+
* @returns an {@link AssistantMessageEventStream} that receives events
|
|
34
|
+
* as the upstream gRPC stream progresses.
|
|
35
|
+
*/
|
|
36
|
+
export function streamDevin(
|
|
37
|
+
model: Model<Api>,
|
|
38
|
+
context: Context,
|
|
39
|
+
options?: SimpleStreamOptions,
|
|
40
|
+
): AssistantMessageEventStream {
|
|
41
|
+
const stream = createAssistantMessageEventStream();
|
|
42
|
+
|
|
43
|
+
// Drive the upstream async iterator inside an IIFE so the returned
|
|
44
|
+
// stream is populated asynchronously.
|
|
45
|
+
void (async () => {
|
|
46
|
+
const output: AssistantMessage = {
|
|
47
|
+
role: 'assistant',
|
|
48
|
+
content: [],
|
|
49
|
+
api: model.api,
|
|
50
|
+
provider: model.provider,
|
|
51
|
+
model: model.id,
|
|
52
|
+
usage: {
|
|
53
|
+
input: 0,
|
|
54
|
+
output: 0,
|
|
55
|
+
cacheRead: 0,
|
|
56
|
+
cacheWrite: 0,
|
|
57
|
+
totalTokens: 0,
|
|
58
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
59
|
+
},
|
|
60
|
+
stopReason: 'stop',
|
|
61
|
+
timestamp: Date.now(),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Block-tracking state. Cognition's wire format has no
|
|
65
|
+
// `tool_call_end` event, so the end of a tool call is implicit:
|
|
66
|
+
// a new `tool_call_start` or stream `finish` closes the prior call.
|
|
67
|
+
let textBlockOpen = false;
|
|
68
|
+
let thinkingBlockOpen = false;
|
|
69
|
+
let currentToolCallIndex = -1;
|
|
70
|
+
let partialJson = '';
|
|
71
|
+
let currentToolCallId = '';
|
|
72
|
+
let currentToolCallName = '';
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const apiKey = options?.apiKey;
|
|
76
|
+
if (!apiKey) throw new Error('No Devin API key. Run /login devin');
|
|
77
|
+
|
|
78
|
+
const { messages, tools } = mapContextToChat(context);
|
|
79
|
+
stream.push({ type: 'start', partial: output });
|
|
80
|
+
|
|
81
|
+
for await (const ev of streamChatEvents({
|
|
82
|
+
apiKey,
|
|
83
|
+
modelUid: model.id,
|
|
84
|
+
messages,
|
|
85
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
86
|
+
signal: options?.signal,
|
|
87
|
+
completionOpts: {
|
|
88
|
+
maxOutputTokens: options?.maxTokens,
|
|
89
|
+
},
|
|
90
|
+
})) {
|
|
91
|
+
handleEvent(ev);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Safety: close any still-open blocks at stream end.
|
|
95
|
+
closeTextBlock();
|
|
96
|
+
closeThinkingBlock();
|
|
97
|
+
if (currentToolCallIndex >= 0) {
|
|
98
|
+
closeToolCall(
|
|
99
|
+
stream,
|
|
100
|
+
output,
|
|
101
|
+
currentToolCallIndex,
|
|
102
|
+
partialJson,
|
|
103
|
+
currentToolCallId,
|
|
104
|
+
currentToolCallName,
|
|
105
|
+
);
|
|
106
|
+
currentToolCallIndex = -1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
stream.push({
|
|
110
|
+
type: 'done',
|
|
111
|
+
reason: output.stopReason as 'stop' | 'length' | 'toolUse',
|
|
112
|
+
message: output,
|
|
113
|
+
});
|
|
114
|
+
stream.end();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
output.stopReason = options?.signal?.aborted ? 'aborted' : 'error';
|
|
117
|
+
output.errorMessage = error instanceof Error ? error.message : String(error);
|
|
118
|
+
stream.push({
|
|
119
|
+
type: 'error',
|
|
120
|
+
reason: output.stopReason as 'aborted' | 'error',
|
|
121
|
+
error: output,
|
|
122
|
+
});
|
|
123
|
+
stream.end();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Dispatch a single {@link CloudChatEvent} to the appropriate
|
|
128
|
+
* pi event(s), mutating `output` and the block-tracking state.
|
|
129
|
+
*/
|
|
130
|
+
function handleEvent(ev: CloudChatEvent): void {
|
|
131
|
+
switch (ev.kind) {
|
|
132
|
+
case 'text': {
|
|
133
|
+
// A text delta ends any open thinking block.
|
|
134
|
+
closeThinkingBlock();
|
|
135
|
+
if (!textBlockOpen) {
|
|
136
|
+
output.content.push({ type: 'text', text: '' });
|
|
137
|
+
stream.push({
|
|
138
|
+
type: 'text_start',
|
|
139
|
+
contentIndex: output.content.length - 1,
|
|
140
|
+
partial: output,
|
|
141
|
+
});
|
|
142
|
+
textBlockOpen = true;
|
|
143
|
+
}
|
|
144
|
+
const idx = output.content.length - 1;
|
|
145
|
+
const block = output.content[idx];
|
|
146
|
+
if (block.type === 'text') {
|
|
147
|
+
block.text += ev.text;
|
|
148
|
+
stream.push({
|
|
149
|
+
type: 'text_delta',
|
|
150
|
+
contentIndex: idx,
|
|
151
|
+
delta: ev.text,
|
|
152
|
+
partial: output,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case 'reasoning': {
|
|
159
|
+
// A reasoning delta ends any open text block.
|
|
160
|
+
closeTextBlock();
|
|
161
|
+
if (!thinkingBlockOpen) {
|
|
162
|
+
output.content.push({ type: 'thinking', thinking: '' });
|
|
163
|
+
stream.push({
|
|
164
|
+
type: 'thinking_start',
|
|
165
|
+
contentIndex: output.content.length - 1,
|
|
166
|
+
partial: output,
|
|
167
|
+
});
|
|
168
|
+
thinkingBlockOpen = true;
|
|
169
|
+
}
|
|
170
|
+
const idx = output.content.length - 1;
|
|
171
|
+
const block = output.content[idx];
|
|
172
|
+
if (block.type === 'thinking') {
|
|
173
|
+
block.thinking += ev.text;
|
|
174
|
+
stream.push({
|
|
175
|
+
type: 'thinking_delta',
|
|
176
|
+
contentIndex: idx,
|
|
177
|
+
delta: ev.text,
|
|
178
|
+
partial: output,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
case 'tool_call_start': {
|
|
185
|
+
// A new tool call ends any open text/thinking block,
|
|
186
|
+
// and implicitly closes the previous tool call.
|
|
187
|
+
closeTextBlock();
|
|
188
|
+
closeThinkingBlock();
|
|
189
|
+
if (currentToolCallIndex >= 0) {
|
|
190
|
+
closeToolCall(
|
|
191
|
+
stream,
|
|
192
|
+
output,
|
|
193
|
+
currentToolCallIndex,
|
|
194
|
+
partialJson,
|
|
195
|
+
currentToolCallId,
|
|
196
|
+
currentToolCallName,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
currentToolCallId = ev.id;
|
|
200
|
+
currentToolCallName = ev.name;
|
|
201
|
+
partialJson = '';
|
|
202
|
+
output.content.push({
|
|
203
|
+
type: 'toolCall',
|
|
204
|
+
id: ev.id,
|
|
205
|
+
name: ev.name,
|
|
206
|
+
arguments: {},
|
|
207
|
+
});
|
|
208
|
+
currentToolCallIndex = output.content.length - 1;
|
|
209
|
+
stream.push({
|
|
210
|
+
type: 'toolcall_start',
|
|
211
|
+
contentIndex: currentToolCallIndex,
|
|
212
|
+
partial: output,
|
|
213
|
+
});
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case 'tool_call_args': {
|
|
218
|
+
// Defensive: args without a preceding start should not
|
|
219
|
+
// happen on Cognition's wire format — ignore them.
|
|
220
|
+
if (currentToolCallIndex < 0) break;
|
|
221
|
+
partialJson += ev.argsDelta;
|
|
222
|
+
const block = output.content[currentToolCallIndex];
|
|
223
|
+
if (block.type === 'toolCall') {
|
|
224
|
+
try {
|
|
225
|
+
block.arguments = JSON.parse(partialJson);
|
|
226
|
+
} catch {
|
|
227
|
+
// Incomplete JSON — keep accumulating deltas.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
stream.push({
|
|
231
|
+
type: 'toolcall_delta',
|
|
232
|
+
contentIndex: currentToolCallIndex,
|
|
233
|
+
delta: ev.argsDelta,
|
|
234
|
+
partial: output,
|
|
235
|
+
});
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
case 'finish': {
|
|
240
|
+
closeTextBlock();
|
|
241
|
+
closeThinkingBlock();
|
|
242
|
+
if (currentToolCallIndex >= 0) {
|
|
243
|
+
closeToolCall(
|
|
244
|
+
stream,
|
|
245
|
+
output,
|
|
246
|
+
currentToolCallIndex,
|
|
247
|
+
partialJson,
|
|
248
|
+
currentToolCallId,
|
|
249
|
+
currentToolCallName,
|
|
250
|
+
);
|
|
251
|
+
currentToolCallIndex = -1;
|
|
252
|
+
}
|
|
253
|
+
output.stopReason =
|
|
254
|
+
ev.reason === 'tool_calls'
|
|
255
|
+
? 'toolUse'
|
|
256
|
+
: ev.reason === 'length'
|
|
257
|
+
? 'length'
|
|
258
|
+
: 'stop';
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
case 'usage': {
|
|
263
|
+
output.usage.input = ev.promptTokens ?? 0;
|
|
264
|
+
output.usage.output = ev.completionTokens ?? 0;
|
|
265
|
+
output.usage.cacheRead = ev.cachedInputTokens ?? 0;
|
|
266
|
+
output.usage.cacheWrite = ev.cacheCreationInputTokens ?? 0;
|
|
267
|
+
output.usage.totalTokens =
|
|
268
|
+
ev.totalTokens ?? output.usage.input + output.usage.output;
|
|
269
|
+
// calculateCost mutates output.usage.cost in place.
|
|
270
|
+
calculateCost(model, output.usage);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Close the open text block, if any, emitting `text_end`. */
|
|
277
|
+
function closeTextBlock(): void {
|
|
278
|
+
if (!textBlockOpen) return;
|
|
279
|
+
const idx = output.content.length - 1;
|
|
280
|
+
const block = output.content[idx];
|
|
281
|
+
if (block.type === 'text') {
|
|
282
|
+
stream.push({
|
|
283
|
+
type: 'text_end',
|
|
284
|
+
contentIndex: idx,
|
|
285
|
+
content: block.text,
|
|
286
|
+
partial: output,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
textBlockOpen = false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Close the open thinking block, if any, emitting `thinking_end`. */
|
|
293
|
+
function closeThinkingBlock(): void {
|
|
294
|
+
if (!thinkingBlockOpen) return;
|
|
295
|
+
const idx = output.content.length - 1;
|
|
296
|
+
const block = output.content[idx];
|
|
297
|
+
if (block.type === 'thinking') {
|
|
298
|
+
stream.push({
|
|
299
|
+
type: 'thinking_end',
|
|
300
|
+
contentIndex: idx,
|
|
301
|
+
content: block.thinking,
|
|
302
|
+
partial: output,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
thinkingBlockOpen = false;
|
|
306
|
+
}
|
|
307
|
+
})();
|
|
308
|
+
|
|
309
|
+
return stream;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Close a tool-call block: attempt a final JSON parse of the accumulated
|
|
314
|
+
* args delta and emit `toolcall_end` with the resolved {@link ToolCall}.
|
|
315
|
+
*
|
|
316
|
+
* Defined at module scope so it is hoisted and usable from the IIFE
|
|
317
|
+
* closure without forward-reference concerns.
|
|
318
|
+
*/
|
|
319
|
+
function closeToolCall(
|
|
320
|
+
stream: AssistantMessageEventStream,
|
|
321
|
+
output: AssistantMessage,
|
|
322
|
+
index: number,
|
|
323
|
+
partialJson: string,
|
|
324
|
+
id: string,
|
|
325
|
+
name: string,
|
|
326
|
+
): void {
|
|
327
|
+
const block = output.content[index];
|
|
328
|
+
if (block.type !== 'toolCall') return;
|
|
329
|
+
// One last parse attempt in case the final delta completed the JSON.
|
|
330
|
+
try {
|
|
331
|
+
block.arguments = JSON.parse(partialJson);
|
|
332
|
+
} catch {
|
|
333
|
+
// Leave arguments as the last successfully-parsed value (or `{}`).
|
|
334
|
+
}
|
|
335
|
+
stream.push({
|
|
336
|
+
type: 'toolcall_end',
|
|
337
|
+
contentIndex: index,
|
|
338
|
+
toolCall: { type: 'toolCall', id, name, arguments: block.arguments },
|
|
339
|
+
partial: output,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
@@ -52,8 +52,8 @@ const FULL_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls", "ques
|
|
|
52
52
|
|
|
53
53
|
const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
54
54
|
const SUBAGENT_FILES: Record<string, string> = {
|
|
55
|
-
coder: path.join(SOURCE_ROOT, "subagent", "agents", "coder.md"),
|
|
56
|
-
architect: path.join(SOURCE_ROOT, "subagent", "agents", "architect.md"),
|
|
55
|
+
coder: path.join(SOURCE_ROOT, "..", "subagent", "agents", "coder.md"),
|
|
56
|
+
architect: path.join(SOURCE_ROOT, "..", "subagent", "agents", "architect.md"),
|
|
57
57
|
};
|
|
58
58
|
|
|
59
59
|
interface ModeConfig {
|
package/plugins/index.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import devinAuthPlugin from "./devin-auth/extensions/index.ts";
|
|
6
|
+
import emberPlugin from "./ember/index.ts";
|
|
7
|
+
import subagentPlugin from "./subagent/extensions/index.ts";
|
|
8
|
+
|
|
9
|
+
type PluginId = "ember" | "subagent" | "devin-auth";
|
|
10
|
+
type StackPlugin = {
|
|
11
|
+
id: PluginId;
|
|
12
|
+
description: string;
|
|
13
|
+
extension: (pi: ExtensionAPI) => void | Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type StackPluginConfig = {
|
|
17
|
+
plugins?: unknown;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const CONFIG_RELATIVE_PATH = path.join(".pi", "ember-stack.json");
|
|
21
|
+
const DEFAULT_PLUGIN_IDS: readonly PluginId[] = [
|
|
22
|
+
"ember",
|
|
23
|
+
"subagent",
|
|
24
|
+
"devin-auth",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const PLUGINS: readonly StackPlugin[] = [
|
|
28
|
+
{
|
|
29
|
+
id: "ember",
|
|
30
|
+
description: "Ember modes, questionnaire, footer, and compact edit rendering",
|
|
31
|
+
extension: emberPlugin,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "subagent",
|
|
35
|
+
description: "Bundled subagent tool and agent definitions",
|
|
36
|
+
extension: subagentPlugin,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "devin-auth",
|
|
40
|
+
description: "Devin OAuth provider, model catalog, and streaming transport",
|
|
41
|
+
extension: devinAuthPlugin,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function isPluginId(value: unknown): value is PluginId {
|
|
46
|
+
return typeof value === "string" && PLUGINS.some((plugin) => plugin.id === value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getConfigPath(cwd: string): string {
|
|
50
|
+
return path.join(cwd, CONFIG_RELATIVE_PATH);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readEnabledPlugins(cwd: string): Set<PluginId> {
|
|
54
|
+
const configPath = getConfigPath(cwd);
|
|
55
|
+
if (!fs.existsSync(configPath)) return new Set(DEFAULT_PLUGIN_IDS);
|
|
56
|
+
|
|
57
|
+
let config: StackPluginConfig;
|
|
58
|
+
try {
|
|
59
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf-8")) as StackPluginConfig;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Invalid ${CONFIG_RELATIVE_PATH}: ${error instanceof Error ? error.message : String(error)}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!Array.isArray(config.plugins)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${CONFIG_RELATIVE_PATH} must contain a plugins array with known plugin IDs.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const unknownPlugin = config.plugins.find((pluginId) => !isPluginId(pluginId));
|
|
73
|
+
if (unknownPlugin !== undefined) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Unknown pi-ember-stack plugin ${String(unknownPlugin)} in ${CONFIG_RELATIVE_PATH}.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return new Set(config.plugins as PluginId[]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeEnabledPlugins(cwd: string, enabledPlugins: Set<PluginId>): void {
|
|
83
|
+
const configPath = getConfigPath(cwd);
|
|
84
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
85
|
+
fs.writeFileSync(
|
|
86
|
+
configPath,
|
|
87
|
+
`${JSON.stringify({ plugins: PLUGINS.filter((plugin) => enabledPlugins.has(plugin.id)).map((plugin) => plugin.id) }, null, 2)}\n`,
|
|
88
|
+
"utf-8",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatPluginChoice(plugin: StackPlugin, enabledPlugins: Set<PluginId>): string {
|
|
93
|
+
return `${enabledPlugins.has(plugin.id) ? "[on]" : "[off]"} ${plugin.id} — ${plugin.description}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function registerPluginCommand(
|
|
97
|
+
pi: ExtensionAPI,
|
|
98
|
+
cwd: string,
|
|
99
|
+
enabledPlugins: Set<PluginId>,
|
|
100
|
+
): void {
|
|
101
|
+
pi.registerCommand("stack-plugins", {
|
|
102
|
+
description: "Show or toggle pi-ember-stack plugins",
|
|
103
|
+
handler: async (_args, ctx) => {
|
|
104
|
+
if (!ctx.hasUI) {
|
|
105
|
+
ctx.ui.notify(`Enabled pi-ember-stack plugins: ${[...enabledPlugins].join(", ")}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const choices = PLUGINS.map((plugin) => formatPluginChoice(plugin, enabledPlugins));
|
|
110
|
+
const selected = await ctx.ui.select("Plugin to toggle", choices);
|
|
111
|
+
if (!selected) return;
|
|
112
|
+
|
|
113
|
+
const selectedIndex = choices.indexOf(selected);
|
|
114
|
+
const plugin = PLUGINS[selectedIndex];
|
|
115
|
+
if (!plugin) return;
|
|
116
|
+
|
|
117
|
+
if (enabledPlugins.has(plugin.id)) {
|
|
118
|
+
enabledPlugins.delete(plugin.id);
|
|
119
|
+
} else {
|
|
120
|
+
enabledPlugins.add(plugin.id);
|
|
121
|
+
}
|
|
122
|
+
writeEnabledPlugins(cwd, enabledPlugins);
|
|
123
|
+
ctx.ui.notify(
|
|
124
|
+
`${plugin.id} ${enabledPlugins.has(plugin.id) ? "enabled" : "disabled"}. Restart pi to apply the change.`,
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default async function piEmberStackPlugin(pi: ExtensionAPI): Promise<void> {
|
|
131
|
+
const cwd = process.cwd();
|
|
132
|
+
const enabledPlugins = readEnabledPlugins(cwd);
|
|
133
|
+
for (const plugin of PLUGINS) {
|
|
134
|
+
if (!enabledPlugins.has(plugin.id)) continue;
|
|
135
|
+
await plugin.extension(pi);
|
|
136
|
+
}
|
|
137
|
+
registerPluginCommand(pi, cwd, enabledPlugins);
|
|
138
|
+
}
|
|
@@ -1,96 +1,96 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared model resolution for pi-subagent.
|
|
3
|
-
*
|
|
4
|
-
* Provides a single canonical resolveModel() used by both the tool handler
|
|
5
|
-
* (index.ts) and the event-driven service path (service.ts), ensuring
|
|
6
|
-
* consistent error reporting across all sub-agent invocation paths.
|
|
7
|
-
*
|
|
8
|
-
* Queries the parent ModelRegistry first (catches custom-configured models
|
|
9
|
-
* with overridden base URLs, headers, compatibility settings). Falls back
|
|
10
|
-
* to the built-in registry for unconfigured models.
|
|
11
|
-
* For unqualified names (no provider prefix), known naming conventions
|
|
12
|
-
* are tried before assuming Anthropic.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
16
|
-
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
|
|
18
|
-
type BuiltInModelResolver = (provider: string, id: string) => Model<any> | undefined;
|
|
19
|
-
|
|
20
|
-
interface ModelModule {
|
|
21
|
-
getModel: BuiltInModelResolver;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Pi 0.80 exposes getModel from /compat; 0.79 exported it from the main entry.
|
|
25
|
-
const { getModel } = (await import("@earendil-works/pi-ai/compat").catch(
|
|
26
|
-
() => import("@earendil-works/pi-ai"),
|
|
27
|
-
)) as ModelModule;
|
|
28
|
-
|
|
29
|
-
export interface ResolvedModel {
|
|
30
|
-
model: Model<any> | null;
|
|
31
|
-
attempted: string[];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Known provider prefixes for unqualified model names. */
|
|
35
|
-
const KNOWN_PROVIDERS: [string, RegExp][] = [
|
|
36
|
-
["openai", /^gpt-/i],
|
|
37
|
-
["anthropic", /^claude-/i],
|
|
38
|
-
["google", /^gemini-/i],
|
|
39
|
-
["cohere", /^command-/i],
|
|
40
|
-
["deepseek", /^(deepseek-|ds-)/i],
|
|
41
|
-
["mistral", /^mistral-/i],
|
|
42
|
-
["groq", /^(groq-|llama-)/i],
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
function tryGetModel(
|
|
46
|
-
provider: string,
|
|
47
|
-
id: string,
|
|
48
|
-
modelRegistry?: ModelRegistry,
|
|
49
|
-
): Model<any> | null {
|
|
50
|
-
// Query parent ModelRegistry first — it includes custom-configured models
|
|
51
|
-
// (overridden base URLs, headers, compatibility settings, per-model overrides).
|
|
52
|
-
// Fall back to built-in registry for unconfigured models.
|
|
53
|
-
if (modelRegistry) {
|
|
54
|
-
const found = modelRegistry.find(provider as any, id as any) ?? null;
|
|
55
|
-
if (found) return found;
|
|
56
|
-
}
|
|
57
|
-
const builtIn = getModel(provider as any, id as any) ?? null;
|
|
58
|
-
if (builtIn) return builtIn;
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function resolveModel(
|
|
63
|
-
modelName: string | undefined,
|
|
64
|
-
parentModel: Model<any> | undefined,
|
|
65
|
-
modelRegistry?: ModelRegistry,
|
|
66
|
-
): ResolvedModel {
|
|
67
|
-
const attempted: string[] = [];
|
|
68
|
-
if (modelName) {
|
|
69
|
-
const idx = modelName.indexOf("/");
|
|
70
|
-
if (idx > 0) {
|
|
71
|
-
// Provider-qualified: "openai/gpt-4o" or "openrouter/anthropic/claude-3.5"
|
|
72
|
-
const provider = modelName.slice(0, idx);
|
|
73
|
-
const id = modelName.slice(idx + 1);
|
|
74
|
-
attempted.push(modelName);
|
|
75
|
-
const found = tryGetModel(provider, id, modelRegistry);
|
|
76
|
-
if (found) return { model: found, attempted };
|
|
77
|
-
} else {
|
|
78
|
-
// Unqualified: try known providers by naming convention
|
|
79
|
-
for (const [provider, pattern] of KNOWN_PROVIDERS) {
|
|
80
|
-
if (pattern.test(modelName)) {
|
|
81
|
-
attempted.push(`${provider}/${modelName}`);
|
|
82
|
-
const found = tryGetModel(provider, modelName, modelRegistry);
|
|
83
|
-
if (found) return { model: found, attempted };
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
// Fall back to Anthropic shorthand (backward compat)
|
|
87
|
-
attempted.push(`anthropic/${modelName}`);
|
|
88
|
-
const found = tryGetModel("anthropic", modelName, modelRegistry);
|
|
89
|
-
if (found) return { model: found, attempted };
|
|
90
|
-
}
|
|
91
|
-
} else if (parentModel) {
|
|
92
|
-
attempted.push(`${parentModel.provider}/${parentModel.id}`);
|
|
93
|
-
return { model: parentModel, attempted };
|
|
94
|
-
}
|
|
95
|
-
return { model: null, attempted };
|
|
96
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Shared model resolution for pi-subagent.
|
|
3
|
+
*
|
|
4
|
+
* Provides a single canonical resolveModel() used by both the tool handler
|
|
5
|
+
* (index.ts) and the event-driven service path (service.ts), ensuring
|
|
6
|
+
* consistent error reporting across all sub-agent invocation paths.
|
|
7
|
+
*
|
|
8
|
+
* Queries the parent ModelRegistry first (catches custom-configured models
|
|
9
|
+
* with overridden base URLs, headers, compatibility settings). Falls back
|
|
10
|
+
* to the built-in registry for unconfigured models.
|
|
11
|
+
* For unqualified names (no provider prefix), known naming conventions
|
|
12
|
+
* are tried before assuming Anthropic.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
16
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
type BuiltInModelResolver = (provider: string, id: string) => Model<any> | undefined;
|
|
19
|
+
|
|
20
|
+
interface ModelModule {
|
|
21
|
+
getModel: BuiltInModelResolver;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Pi 0.80 exposes getModel from /compat; 0.79 exported it from the main entry.
|
|
25
|
+
const { getModel } = (await import("@earendil-works/pi-ai/compat").catch(
|
|
26
|
+
() => import("@earendil-works/pi-ai"),
|
|
27
|
+
)) as ModelModule;
|
|
28
|
+
|
|
29
|
+
export interface ResolvedModel {
|
|
30
|
+
model: Model<any> | null;
|
|
31
|
+
attempted: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Known provider prefixes for unqualified model names. */
|
|
35
|
+
const KNOWN_PROVIDERS: [string, RegExp][] = [
|
|
36
|
+
["openai", /^gpt-/i],
|
|
37
|
+
["anthropic", /^claude-/i],
|
|
38
|
+
["google", /^gemini-/i],
|
|
39
|
+
["cohere", /^command-/i],
|
|
40
|
+
["deepseek", /^(deepseek-|ds-)/i],
|
|
41
|
+
["mistral", /^mistral-/i],
|
|
42
|
+
["groq", /^(groq-|llama-)/i],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function tryGetModel(
|
|
46
|
+
provider: string,
|
|
47
|
+
id: string,
|
|
48
|
+
modelRegistry?: ModelRegistry,
|
|
49
|
+
): Model<any> | null {
|
|
50
|
+
// Query parent ModelRegistry first — it includes custom-configured models
|
|
51
|
+
// (overridden base URLs, headers, compatibility settings, per-model overrides).
|
|
52
|
+
// Fall back to built-in registry for unconfigured models.
|
|
53
|
+
if (modelRegistry) {
|
|
54
|
+
const found = modelRegistry.find(provider as any, id as any) ?? null;
|
|
55
|
+
if (found) return found;
|
|
56
|
+
}
|
|
57
|
+
const builtIn = getModel(provider as any, id as any) ?? null;
|
|
58
|
+
if (builtIn) return builtIn;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveModel(
|
|
63
|
+
modelName: string | undefined,
|
|
64
|
+
parentModel: Model<any> | undefined,
|
|
65
|
+
modelRegistry?: ModelRegistry,
|
|
66
|
+
): ResolvedModel {
|
|
67
|
+
const attempted: string[] = [];
|
|
68
|
+
if (modelName) {
|
|
69
|
+
const idx = modelName.indexOf("/");
|
|
70
|
+
if (idx > 0) {
|
|
71
|
+
// Provider-qualified: "openai/gpt-4o" or "openrouter/anthropic/claude-3.5"
|
|
72
|
+
const provider = modelName.slice(0, idx);
|
|
73
|
+
const id = modelName.slice(idx + 1);
|
|
74
|
+
attempted.push(modelName);
|
|
75
|
+
const found = tryGetModel(provider, id, modelRegistry);
|
|
76
|
+
if (found) return { model: found, attempted };
|
|
77
|
+
} else {
|
|
78
|
+
// Unqualified: try known providers by naming convention
|
|
79
|
+
for (const [provider, pattern] of KNOWN_PROVIDERS) {
|
|
80
|
+
if (pattern.test(modelName)) {
|
|
81
|
+
attempted.push(`${provider}/${modelName}`);
|
|
82
|
+
const found = tryGetModel(provider, modelName, modelRegistry);
|
|
83
|
+
if (found) return { model: found, attempted };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Fall back to Anthropic shorthand (backward compat)
|
|
87
|
+
attempted.push(`anthropic/${modelName}`);
|
|
88
|
+
const found = tryGetModel("anthropic", modelName, modelRegistry);
|
|
89
|
+
if (found) return { model: found, attempted };
|
|
90
|
+
}
|
|
91
|
+
} else if (parentModel) {
|
|
92
|
+
attempted.push(`${parentModel.provider}/${parentModel.id}`);
|
|
93
|
+
return { model: parentModel, attempted };
|
|
94
|
+
}
|
|
95
|
+
return { model: null, attempted };
|
|
96
|
+
}
|