@meetopenbot/pi 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.js +6 -32
- package/dist/credits.js +51 -0
- package/dist/index.js +13 -294
- package/dist/runtime.js +17 -0
- package/dist/session.js +21 -17
- package/dist/stream.js +29 -19
- package/package.json +2 -2
- package/dist/cloud-mode.js +0 -10
- package/dist/credits-auth.js +0 -90
- package/dist/diff.js +0 -1
- package/dist/format.js +0 -148
package/dist/config.js
CHANGED
|
@@ -1,37 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
const THINKING_LEVELS = new Set([
|
|
3
|
-
'off',
|
|
4
|
-
'minimal',
|
|
5
|
-
'low',
|
|
6
|
-
'medium',
|
|
7
|
-
'high',
|
|
8
|
-
'xhigh',
|
|
9
|
-
]);
|
|
1
|
+
import { trimmedString, vendorModelId } from '@meetopenbot/plugin-sdk';
|
|
10
2
|
export const resolveConfig = (context, channelCwd) => {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
: undefined;
|
|
15
|
-
const asString = (value) => typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
3
|
+
const raw = trimmedString(context.config.model);
|
|
4
|
+
const provider = raw?.includes('/') ? raw.split('/')[0] : undefined;
|
|
5
|
+
const model = vendorModelId(raw);
|
|
16
6
|
return {
|
|
17
7
|
cwd: channelCwd || process.cwd(),
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
model: asString(config.model),
|
|
21
|
-
thinkingLevel,
|
|
22
|
-
tools: asString(config.tools),
|
|
23
|
-
excludeTools: asString(config.excludeTools),
|
|
24
|
-
noTools: config.noTools === 'all' || config.noTools === 'builtin' ? config.noTools : undefined,
|
|
25
|
-
systemPrompt: asString(config.systemPrompt),
|
|
26
|
-
authMode: resolveAuthMode(config),
|
|
8
|
+
provider,
|
|
9
|
+
model,
|
|
27
10
|
};
|
|
28
11
|
};
|
|
29
|
-
export const parseToolList = (value) => {
|
|
30
|
-
if (!value)
|
|
31
|
-
return undefined;
|
|
32
|
-
const tools = value
|
|
33
|
-
.split(',')
|
|
34
|
-
.map((tool) => tool.trim())
|
|
35
|
-
.filter(Boolean);
|
|
36
|
-
return tools.length > 0 ? tools : undefined;
|
|
37
|
-
};
|
package/dist/credits.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { CREDITS_API_KEY_PLACEHOLDER, CREDITS_NOT_CONFIGURED_MESSAGE, INTEGRATIONS_TOKEN_HEADER, creditsErrorMessage as mapCreditsError, creditsAuthFailedMessage, creditsProviderBaseUrl as sdkCreditsProviderBaseUrl, isAuthErrorMessage, llmAuthNotConfiguredMessage, resolveByokApiKey, resolveCreditsAuthConfig, } from '@meetopenbot/plugin-sdk';
|
|
2
|
+
export { CREDITS_API_KEY_PLACEHOLDER, CREDITS_NOT_CONFIGURED_MESSAGE, INTEGRATIONS_TOKEN_HEADER, isAuthErrorMessage, llmAuthNotConfiguredMessage, resolveByokApiKey, resolveCreditsAuthConfig, };
|
|
3
|
+
/**
|
|
4
|
+
* Gateway paths matching each Pi provider's default base URL shape:
|
|
5
|
+
* - openai models use `https://api.openai.com/v1`
|
|
6
|
+
* - anthropic models use `https://api.anthropic.com` (SDK appends `/v1`)
|
|
7
|
+
* - deepseek is OpenAI-compat; `/v1` keeps metering on `/v1/chat/completions`
|
|
8
|
+
*
|
|
9
|
+
* Google Gemini is omitted: Pi speaks the native Generative Language API, while
|
|
10
|
+
* the credits gateway only proxies Gemini's OpenAI-compatible surface.
|
|
11
|
+
*/
|
|
12
|
+
export const CREDITS_PROVIDERS = [
|
|
13
|
+
{ id: 'openai', basePath: 'openai/v1', envVar: 'OPENAI_API_KEY' },
|
|
14
|
+
{ id: 'anthropic', basePath: 'anthropic', envVar: 'ANTHROPIC_API_KEY' },
|
|
15
|
+
{ id: 'deepseek', basePath: 'deepseek/v1', envVar: 'DEEPSEEK_API_KEY' },
|
|
16
|
+
];
|
|
17
|
+
const CREDITS_PROVIDER_IDS = new Set(CREDITS_PROVIDERS.map((provider) => provider.id));
|
|
18
|
+
const PI_CREDITS_PATHS = Object.fromEntries(CREDITS_PROVIDERS.map((provider) => [provider.id, provider.basePath]));
|
|
19
|
+
export function isCreditsProvider(provider) {
|
|
20
|
+
return CREDITS_PROVIDER_IDS.has(provider);
|
|
21
|
+
}
|
|
22
|
+
export function creditsProviderBaseUrl(config, provider) {
|
|
23
|
+
return sdkCreditsProviderBaseUrl(config, provider, PI_CREDITS_PATHS);
|
|
24
|
+
}
|
|
25
|
+
export function applyCreditsProviders(modelRegistry, authStorage, config) {
|
|
26
|
+
const apiKey = config.token || CREDITS_API_KEY_PLACEHOLDER;
|
|
27
|
+
const headers = { [INTEGRATIONS_TOKEN_HEADER]: config.token };
|
|
28
|
+
for (const provider of CREDITS_PROVIDERS) {
|
|
29
|
+
modelRegistry.registerProvider(provider.id, {
|
|
30
|
+
baseUrl: creditsProviderBaseUrl(config, provider.id),
|
|
31
|
+
headers,
|
|
32
|
+
});
|
|
33
|
+
authStorage.setRuntimeApiKey(provider.id, apiKey);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export const CREDITS_AUTH_FAILED_MESSAGE = creditsAuthFailedMessage('Pi');
|
|
37
|
+
export function creditsErrorMessage(message) {
|
|
38
|
+
return mapCreditsError(message, { agentName: 'Pi' });
|
|
39
|
+
}
|
|
40
|
+
export function remapPiError(message) {
|
|
41
|
+
if (resolveByokApiKey('openai') ||
|
|
42
|
+
resolveByokApiKey('anthropic') ||
|
|
43
|
+
resolveByokApiKey('google') ||
|
|
44
|
+
resolveByokApiKey('deepseek')) {
|
|
45
|
+
return message;
|
|
46
|
+
}
|
|
47
|
+
return creditsErrorMessage(message) ?? message;
|
|
48
|
+
}
|
|
49
|
+
export function unsupportedCreditsProviderMessage(provider) {
|
|
50
|
+
return `OpenBot Credits does not support the "${provider}" provider. Use openai, anthropic, or deepseek.`;
|
|
51
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,299 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import { formatPiError, formatToolResult } from './format.js';
|
|
6
|
-
import { disposePiSessionsForThread, getOrCreatePiSession } from './session.js';
|
|
7
|
-
import { streamPiPrompt } from './stream.js';
|
|
8
|
-
const BYOK_PROVIDERS = {
|
|
9
|
-
anthropic: {
|
|
10
|
-
envVar: 'ANTHROPIC_API_KEY',
|
|
11
|
-
label: 'Anthropic',
|
|
12
|
-
placeholder: 'sk-ant-...',
|
|
13
|
-
},
|
|
14
|
-
openai: {
|
|
15
|
-
envVar: 'OPENAI_API_KEY',
|
|
16
|
-
label: 'OpenAI',
|
|
17
|
-
placeholder: 'sk-...',
|
|
18
|
-
},
|
|
19
|
-
deepseek: {
|
|
20
|
-
envVar: 'DEEPSEEK_API_KEY',
|
|
21
|
-
label: 'DeepSeek',
|
|
22
|
-
placeholder: 'sk-...',
|
|
23
|
-
},
|
|
24
|
-
google: {
|
|
25
|
-
envVar: 'GEMINI_API_KEY',
|
|
26
|
-
label: 'Google Gemini',
|
|
27
|
-
placeholder: 'AIza...',
|
|
28
|
-
},
|
|
29
|
-
};
|
|
30
|
-
const resolveByokProvider = (provider) => BYOK_PROVIDERS[provider ?? ''] ?? BYOK_PROVIDERS.openai;
|
|
31
|
-
const buildApiKeyWidget = (agentId, threadId, reason, provider) => {
|
|
32
|
-
const byok = resolveByokProvider(provider);
|
|
33
|
-
return uiWidget({
|
|
34
|
-
agentId,
|
|
35
|
-
threadId,
|
|
36
|
-
widget: {
|
|
37
|
-
kind: 'form',
|
|
38
|
-
widgetId: `pi_api_key_request_${Date.now()}`,
|
|
39
|
-
title: `${byok.label} API Key Required`,
|
|
40
|
-
description: `Pi could not authenticate (${reason}). ` +
|
|
41
|
-
`Provide a ${byok.label} API key to continue. ` +
|
|
42
|
-
'The key is stored as a workspace variable on your machine and never leaves your local runtime.',
|
|
43
|
-
fields: [
|
|
44
|
-
{
|
|
45
|
-
id: 'apiKey',
|
|
46
|
-
label: 'API Key',
|
|
47
|
-
type: 'password',
|
|
48
|
-
placeholder: byok.placeholder,
|
|
49
|
-
required: true,
|
|
50
|
-
},
|
|
51
|
-
],
|
|
52
|
-
submitLabel: 'Save API Key',
|
|
53
|
-
metadata: {
|
|
54
|
-
type: 'api_key_request',
|
|
55
|
-
provider: provider && BYOK_PROVIDERS[provider] ? provider : 'openai',
|
|
56
|
-
envVar: byok.envVar,
|
|
57
|
-
source: 'pi',
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
});
|
|
61
|
-
};
|
|
62
|
-
export default definePlugin({
|
|
63
|
-
id: 'pi',
|
|
1
|
+
import { defineAgentPlugin } from '@meetopenbot/plugin-sdk';
|
|
2
|
+
import { remapPiError } from './credits.js';
|
|
3
|
+
import { runPiTurn } from './runtime.js';
|
|
4
|
+
export const plugin = await defineAgentPlugin({
|
|
64
5
|
name: 'Pi',
|
|
65
6
|
description: 'Pi coding agent — read, edit, and run code in your workspace.',
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
...(isCloudMode()
|
|
70
|
-
? {
|
|
71
|
-
authMode: {
|
|
72
|
-
type: 'string',
|
|
73
|
-
description: 'Credits — use your workspace credit balance via OpenBot. BYOK — bring your own provider API key.',
|
|
74
|
-
enum: ['credits', 'byok'],
|
|
75
|
-
default: 'credits',
|
|
76
|
-
},
|
|
77
|
-
}
|
|
78
|
-
: {}),
|
|
79
|
-
agentDir: {
|
|
80
|
-
type: 'string',
|
|
81
|
-
description: 'Pi config directory (default: ~/.pi/agent).',
|
|
82
|
-
},
|
|
83
|
-
provider: {
|
|
84
|
-
type: 'string',
|
|
85
|
-
description: 'Model provider (e.g. anthropic, openai, deepseek).',
|
|
86
|
-
},
|
|
87
|
-
model: {
|
|
88
|
-
type: 'string',
|
|
89
|
-
description: 'Model id (e.g. claude-opus-4-5).',
|
|
90
|
-
},
|
|
91
|
-
thinkingLevel: {
|
|
92
|
-
type: 'string',
|
|
93
|
-
description: 'Extended thinking level.',
|
|
94
|
-
enum: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'],
|
|
95
|
-
default: 'off',
|
|
96
|
-
},
|
|
97
|
-
tools: {
|
|
98
|
-
type: 'string',
|
|
99
|
-
description: 'Comma-separated built-in tools to enable (e.g. read,bash,edit,write,grep,find,ls).',
|
|
100
|
-
},
|
|
101
|
-
excludeTools: {
|
|
102
|
-
type: 'string',
|
|
103
|
-
description: 'Comma-separated tool names to disable.',
|
|
104
|
-
},
|
|
105
|
-
noTools: {
|
|
106
|
-
type: 'string',
|
|
107
|
-
description: 'Disable tools: "all" or "builtin".',
|
|
108
|
-
enum: ['all', 'builtin'],
|
|
109
|
-
},
|
|
110
|
-
systemPrompt: {
|
|
111
|
-
type: 'string',
|
|
112
|
-
description: 'Override Pi system prompt for this agent.',
|
|
113
|
-
},
|
|
114
|
-
},
|
|
7
|
+
models: {
|
|
8
|
+
providers: ['openai', 'anthropic', 'deepseek'],
|
|
9
|
+
default: 'openai/gpt-5.6-luna',
|
|
115
10
|
},
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const prompt = (event.data.content ?? '').trim();
|
|
122
|
-
if (!prompt) {
|
|
123
|
-
yield agentOutput({
|
|
124
|
-
agentId: context.agentId,
|
|
125
|
-
content: 'Send a message to run Pi in this workspace — for example, "List the files here" or "Fix the failing test in src/foo.test.ts".',
|
|
126
|
-
threadId,
|
|
127
|
-
});
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
const channelCwd = handlerCtx.state.channelDetails?.cwd;
|
|
131
|
-
const config = resolveConfig(context, channelCwd);
|
|
132
|
-
const fail = function* (message) {
|
|
133
|
-
const mapped = remapPiError(message, config.authMode);
|
|
134
|
-
if (config.authMode === 'byok' && isAuthErrorMessage(message)) {
|
|
135
|
-
yield buildApiKeyWidget(context.agentId, threadId, mapped, config.provider);
|
|
136
|
-
}
|
|
137
|
-
yield agentOutput({
|
|
138
|
-
agentId: context.agentId,
|
|
139
|
-
content: `**Pi error:** ${mapped}`,
|
|
140
|
-
threadId,
|
|
141
|
-
});
|
|
142
|
-
};
|
|
143
|
-
try {
|
|
144
|
-
const { session } = await getOrCreatePiSession({
|
|
145
|
-
config,
|
|
146
|
-
state: handlerCtx.state,
|
|
147
|
-
storage: context.storage,
|
|
148
|
-
});
|
|
149
|
-
let fullTextContent = '';
|
|
150
|
-
const toolInfoMap = new Map();
|
|
151
|
-
const changedFiles = new Map();
|
|
152
|
-
const snapshot = snapshotWorkspace(config.cwd);
|
|
153
|
-
for await (const chunk of streamPiPrompt(session, prompt, {
|
|
154
|
-
streaming: session.isStreaming,
|
|
155
|
-
})) {
|
|
156
|
-
switch (chunk.type) {
|
|
157
|
-
case 'tool_start':
|
|
158
|
-
toolInfoMap.set(chunk.toolCallId, {
|
|
159
|
-
statusLine: chunk.statusLine,
|
|
160
|
-
args: chunk.args,
|
|
161
|
-
});
|
|
162
|
-
yield uiWidget({
|
|
163
|
-
agentId: context.agentId,
|
|
164
|
-
threadId,
|
|
165
|
-
widget: toolTraceWidget({
|
|
166
|
-
widgetId: chunk.toolCallId,
|
|
167
|
-
groupId: 'pi:tools',
|
|
168
|
-
title: chunk.statusLine,
|
|
169
|
-
body: `**Input**\n\`\`\`json\n${JSON.stringify(chunk.args, null, 2)}\n\`\`\``,
|
|
170
|
-
}),
|
|
171
|
-
});
|
|
172
|
-
break;
|
|
173
|
-
case 'tool_end': {
|
|
174
|
-
const info = toolInfoMap.get(chunk.toolCallId);
|
|
175
|
-
const inputMd = info
|
|
176
|
-
? `**Input**\n\`\`\`json\n${JSON.stringify(info.args, null, 2)}\n\`\`\`\n\n`
|
|
177
|
-
: '';
|
|
178
|
-
const outputMd = `**Output**\n${formatToolResult(chunk.result)}`;
|
|
179
|
-
yield uiWidget({
|
|
180
|
-
agentId: context.agentId,
|
|
181
|
-
threadId,
|
|
182
|
-
widget: toolTraceWidget({
|
|
183
|
-
widgetId: chunk.toolCallId,
|
|
184
|
-
groupId: 'pi:tools',
|
|
185
|
-
title: chunk.statusLine || info?.statusLine || `Tool ${chunk.toolName} finished`,
|
|
186
|
-
body: inputMd + outputMd,
|
|
187
|
-
state: chunk.isError ? 'error' : 'submitted',
|
|
188
|
-
}),
|
|
189
|
-
});
|
|
190
|
-
if (!chunk.isError) {
|
|
191
|
-
const file = diffFileFromMutationTool({
|
|
192
|
-
toolName: chunk.toolName,
|
|
193
|
-
input: info?.args,
|
|
194
|
-
result: chunk.result,
|
|
195
|
-
});
|
|
196
|
-
if (file)
|
|
197
|
-
changedFiles.set(file.path, file);
|
|
198
|
-
}
|
|
199
|
-
break;
|
|
200
|
-
}
|
|
201
|
-
case 'status':
|
|
202
|
-
yield uiWidget({
|
|
203
|
-
agentId: context.agentId,
|
|
204
|
-
threadId,
|
|
205
|
-
widget: toolTraceWidget({
|
|
206
|
-
widgetId: `status-${Date.now()}`,
|
|
207
|
-
groupId: 'pi:tools',
|
|
208
|
-
title: 'Status',
|
|
209
|
-
body: chunk.statusLine,
|
|
210
|
-
}),
|
|
211
|
-
});
|
|
212
|
-
break;
|
|
213
|
-
case 'text': {
|
|
214
|
-
const content = chunk.content.startsWith('**Pi error:** ')
|
|
215
|
-
? `**Pi error:** ${remapPiError(chunk.content.slice('**Pi error:** '.length), config.authMode)}`
|
|
216
|
-
: chunk.content;
|
|
217
|
-
if (fullTextContent)
|
|
218
|
-
fullTextContent += '\n\n';
|
|
219
|
-
fullTextContent += content;
|
|
220
|
-
break;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
if (fullTextContent.trim()) {
|
|
225
|
-
yield agentOutput({
|
|
226
|
-
agentId: context.agentId,
|
|
227
|
-
content: fullTextContent.trim(),
|
|
228
|
-
threadId,
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
const diff = buildDiffWidget({
|
|
232
|
-
widgetId: `pi-diff:${threadId ?? 'run'}:${Date.now()}`,
|
|
233
|
-
files: resolveRunDiffFiles({
|
|
234
|
-
snapshot,
|
|
235
|
-
fallback: changedFiles.values(),
|
|
236
|
-
}),
|
|
237
|
-
});
|
|
238
|
-
if (diff) {
|
|
239
|
-
yield uiWidget({
|
|
240
|
-
agentId: context.agentId,
|
|
241
|
-
threadId,
|
|
242
|
-
widget: diff,
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
catch (error) {
|
|
247
|
-
yield* fail(formatPiError(error));
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
builder.on('client:ui:widget:response', async function* (event, handlerCtx) {
|
|
251
|
-
const { metadata, values, widgetId } = event.data ?? {};
|
|
252
|
-
if (!metadata || metadata.type !== 'api_key_request')
|
|
253
|
-
return;
|
|
254
|
-
if (metadata.source !== 'pi')
|
|
255
|
-
return;
|
|
256
|
-
const apiKey = values?.apiKey;
|
|
257
|
-
if (typeof apiKey !== 'string' || !apiKey)
|
|
258
|
-
return;
|
|
259
|
-
const envVar = typeof metadata.envVar === 'string' ? metadata.envVar : 'OPENAI_API_KEY';
|
|
260
|
-
const storage = context.storage;
|
|
261
|
-
if (!storage) {
|
|
262
|
-
yield agentOutput({
|
|
263
|
-
agentId: context.agentId,
|
|
264
|
-
content: '[pi] no storage available; cannot persist API key.',
|
|
265
|
-
threadId: handlerCtx.state.threadId,
|
|
266
|
-
});
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
try {
|
|
270
|
-
await storage.createVariable({ key: envVar, value: apiKey, secret: true });
|
|
271
|
-
process.env[envVar] = apiKey;
|
|
272
|
-
disposePiSessionsForThread(handlerCtx.state);
|
|
273
|
-
yield uiWidget({
|
|
274
|
-
agentId: context.agentId,
|
|
275
|
-
widget: {
|
|
276
|
-
widgetId: widgetId ?? `pi_api_key_saved_${Date.now()}`,
|
|
277
|
-
kind: 'message',
|
|
278
|
-
title: 'API Key Saved',
|
|
279
|
-
body: `Saved ${envVar} as a workspace variable. You can now continue the conversation.`,
|
|
280
|
-
state: 'submitted',
|
|
281
|
-
},
|
|
282
|
-
});
|
|
283
|
-
yield agentOutput({
|
|
284
|
-
agentId: context.agentId,
|
|
285
|
-
content: `Saved ${envVar} to workspace variables. Re-send your last message to retry.`,
|
|
286
|
-
threadId: handlerCtx.state.threadId,
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
catch (error) {
|
|
290
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
291
|
-
yield agentOutput({
|
|
292
|
-
agentId: context.agentId,
|
|
293
|
-
content: `[pi] failed to save API key: ${errorMessage}`,
|
|
294
|
-
threadId: handlerCtx.state.threadId,
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
});
|
|
11
|
+
emptyPrompt: 'Send a message to run Pi in this workspace — for example, "List the files here" or "Fix the failing test in src/foo.test.ts".',
|
|
12
|
+
run: runPiTurn,
|
|
13
|
+
mapError: (error) => {
|
|
14
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15
|
+
return { kind: 'reply', content: `Pi error: ${remapPiError(message)}` };
|
|
298
16
|
},
|
|
299
17
|
});
|
|
18
|
+
export default plugin;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { resolveConfig } from './config.js';
|
|
2
|
+
import { remapPiError } from './credits.js';
|
|
3
|
+
import { getOrCreatePiSession } from './session.js';
|
|
4
|
+
import { runPiPrompt } from './stream.js';
|
|
5
|
+
export async function runPiTurn({ prompt, handlerCtx, context }) {
|
|
6
|
+
const config = resolveConfig(context, handlerCtx.state.channelDetails?.cwd);
|
|
7
|
+
const { session } = await getOrCreatePiSession({
|
|
8
|
+
config,
|
|
9
|
+
state: handlerCtx.state,
|
|
10
|
+
storage: context.storage,
|
|
11
|
+
});
|
|
12
|
+
const text = await runPiPrompt(session, prompt, { streaming: session.isStreaming });
|
|
13
|
+
if (text.startsWith('Pi error: ')) {
|
|
14
|
+
return `Pi error: ${remapPiError(text.slice('Pi error: '.length))}`;
|
|
15
|
+
}
|
|
16
|
+
return text;
|
|
17
|
+
}
|
package/dist/session.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { AuthStorage, createAgentSession, DefaultResourceLoader, getAgentDir, ModelRegistry, SessionManager, SettingsManager, } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import {
|
|
3
|
-
import { applyCreditsProviders,
|
|
2
|
+
import { isCloudMode } from '@meetopenbot/plugin-sdk';
|
|
3
|
+
import { applyCreditsProviders, isCreditsProvider, llmAuthNotConfiguredMessage, resolveByokApiKey, resolveCreditsAuthConfig, unsupportedCreditsProviderMessage, } from './credits.js';
|
|
4
4
|
import { persistPiState, readPersistedState } from './state.js';
|
|
5
5
|
const sessionCache = new Map();
|
|
6
|
-
const
|
|
6
|
+
const hasPiByok = (provider) => {
|
|
7
|
+
if (provider)
|
|
8
|
+
return Boolean(resolveByokApiKey(provider));
|
|
9
|
+
return Boolean(resolveByokApiKey('openai') ||
|
|
10
|
+
resolveByokApiKey('anthropic') ||
|
|
11
|
+
resolveByokApiKey('google') ||
|
|
12
|
+
resolveByokApiKey('deepseek'));
|
|
13
|
+
};
|
|
14
|
+
const buildSessionKey = (state, byok) => {
|
|
7
15
|
const scope = state.threadId ? `${state.channelId}:${state.threadId}` : state.channelId;
|
|
8
|
-
return `${scope}:${
|
|
16
|
+
return `${scope}:${byok ? 'byok' : 'credits'}`;
|
|
9
17
|
};
|
|
10
18
|
const resolveModel = async (config, modelRegistry) => {
|
|
11
19
|
if (config.provider && config.model) {
|
|
@@ -16,23 +24,24 @@ const resolveModel = async (config, modelRegistry) => {
|
|
|
16
24
|
};
|
|
17
25
|
export const getOrCreatePiSession = async (args) => {
|
|
18
26
|
const { config, state, storage } = args;
|
|
19
|
-
const
|
|
27
|
+
const byok = hasPiByok(config.provider);
|
|
28
|
+
const sessionKey = buildSessionKey(state, byok);
|
|
20
29
|
const cached = sessionCache.get(sessionKey);
|
|
21
30
|
if (cached)
|
|
22
31
|
return cached;
|
|
23
|
-
if (
|
|
32
|
+
if (!byok && config.provider && !isCreditsProvider(config.provider)) {
|
|
24
33
|
throw new Error(unsupportedCreditsProviderMessage(config.provider));
|
|
25
34
|
}
|
|
26
35
|
const cwd = config.cwd || process.cwd();
|
|
27
|
-
const agentDir =
|
|
36
|
+
const agentDir = getAgentDir();
|
|
28
37
|
const persisted = readPersistedState(state);
|
|
29
38
|
const authStorage = AuthStorage.create(`${agentDir}/auth.json`);
|
|
30
39
|
const modelRegistry = ModelRegistry.create(authStorage, `${agentDir}/models.json`);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
const credits = !byok && isCloudMode() ? resolveCreditsAuthConfig() : undefined;
|
|
41
|
+
if (!byok && !credits) {
|
|
42
|
+
throw new Error(llmAuthNotConfiguredMessage(config.provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'));
|
|
43
|
+
}
|
|
44
|
+
if (credits) {
|
|
36
45
|
applyCreditsProviders(modelRegistry, authStorage, credits);
|
|
37
46
|
}
|
|
38
47
|
const model = await resolveModel(config, modelRegistry);
|
|
@@ -41,7 +50,6 @@ export const getOrCreatePiSession = async (args) => {
|
|
|
41
50
|
cwd,
|
|
42
51
|
agentDir,
|
|
43
52
|
settingsManager,
|
|
44
|
-
...(config.systemPrompt ? { systemPromptOverride: () => config.systemPrompt } : {}),
|
|
45
53
|
});
|
|
46
54
|
await loader.reload();
|
|
47
55
|
const sessionManager = persisted.sessionFile
|
|
@@ -53,10 +61,6 @@ export const getOrCreatePiSession = async (args) => {
|
|
|
53
61
|
authStorage,
|
|
54
62
|
modelRegistry,
|
|
55
63
|
model,
|
|
56
|
-
thinkingLevel: config.thinkingLevel,
|
|
57
|
-
tools: parseToolList(config.tools),
|
|
58
|
-
excludeTools: parseToolList(config.excludeTools),
|
|
59
|
-
noTools: config.noTools,
|
|
60
64
|
resourceLoader: loader,
|
|
61
65
|
sessionManager,
|
|
62
66
|
settingsManager,
|
package/dist/stream.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
/**
|
|
3
|
-
export async function
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
const isAssistantMessage = (message) => message.role === 'assistant';
|
|
2
|
+
/** Run a Pi prompt and return the final assistant text. */
|
|
3
|
+
export async function runPiPrompt(session, prompt, options) {
|
|
4
|
+
let assistantText = '';
|
|
5
|
+
let result = '';
|
|
6
6
|
let wake;
|
|
7
7
|
let finished = false;
|
|
8
8
|
let error;
|
|
@@ -10,17 +10,26 @@ export async function* streamPiPrompt(session, prompt, options) {
|
|
|
10
10
|
wake?.();
|
|
11
11
|
wake = undefined;
|
|
12
12
|
};
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
const onEvent = (event) => {
|
|
14
|
+
if (event.type === 'message_update') {
|
|
15
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
16
|
+
if (assistantEvent.type === 'text_delta')
|
|
17
|
+
assistantText += assistantEvent.delta;
|
|
18
|
+
return;
|
|
18
19
|
}
|
|
19
20
|
if (event.type === 'agent_end') {
|
|
21
|
+
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
|
|
22
|
+
if (lastAssistant?.stopReason === 'error' && lastAssistant.errorMessage?.trim()) {
|
|
23
|
+
result = `Pi error: ${lastAssistant.errorMessage.trim()}`;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
result = assistantText;
|
|
27
|
+
}
|
|
20
28
|
finished = true;
|
|
21
29
|
wakeUp();
|
|
22
30
|
}
|
|
23
|
-
}
|
|
31
|
+
};
|
|
32
|
+
const unsubscribe = session.subscribe(onEvent);
|
|
24
33
|
const run = (async () => {
|
|
25
34
|
try {
|
|
26
35
|
if (options?.streaming && session.isStreaming) {
|
|
@@ -39,18 +48,19 @@ export async function* streamPiPrompt(session, prompt, options) {
|
|
|
39
48
|
}
|
|
40
49
|
})();
|
|
41
50
|
try {
|
|
42
|
-
while (!finished
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
while (!finished) {
|
|
52
|
+
await new Promise((resolve) => {
|
|
53
|
+
if (finished) {
|
|
54
|
+
resolve();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
wake = resolve;
|
|
58
|
+
});
|
|
50
59
|
}
|
|
51
60
|
await run;
|
|
52
61
|
if (error)
|
|
53
62
|
throw error;
|
|
63
|
+
return result.trim();
|
|
54
64
|
}
|
|
55
65
|
finally {
|
|
56
66
|
unsubscribe();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meetopenbot/pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "OpenBot agent plugin powered by the Pi coding agent SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@earendil-works/pi-ai": "^0.79.1",
|
|
23
23
|
"@earendil-works/pi-coding-agent": "^0.79.1",
|
|
24
|
-
"@meetopenbot/plugin-sdk": "^0.
|
|
24
|
+
"@meetopenbot/plugin-sdk": "^0.3.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^25.9.2",
|
package/dist/cloud-mode.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/** True when this runtime is a platform-managed cloud deployment. */
|
|
2
|
-
export const isCloudMode = () => process.env.OPENBOT_CLOUD_MODE === '1';
|
|
3
|
-
/** Default auth mode: Credits on cloud, BYOK locally. */
|
|
4
|
-
export const defaultAuthMode = () => (isCloudMode() ? 'credits' : 'byok');
|
|
5
|
-
export function resolveAuthMode(config) {
|
|
6
|
-
if (config.authMode === 'byok' || config.authMode === 'credits') {
|
|
7
|
-
return config.authMode;
|
|
8
|
-
}
|
|
9
|
-
return defaultAuthMode();
|
|
10
|
-
}
|
package/dist/credits-auth.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
export const INTEGRATIONS_TOKEN_HEADER = 'x-openbot-integrations-token';
|
|
2
|
-
export const CREDITS_API_KEY_PLACEHOLDER = 'openbot-credits';
|
|
3
|
-
/**
|
|
4
|
-
* Gateway paths matching each Pi provider's default base URL shape:
|
|
5
|
-
* - openai models use `https://api.openai.com/v1`
|
|
6
|
-
* - anthropic models use `https://api.anthropic.com` (SDK appends `/v1`)
|
|
7
|
-
* - deepseek is OpenAI-compat; `/v1` keeps metering on `/v1/chat/completions`
|
|
8
|
-
*
|
|
9
|
-
* Google Gemini is omitted: Pi speaks the native Generative Language API, while
|
|
10
|
-
* the credits gateway only proxies Gemini's OpenAI-compatible surface.
|
|
11
|
-
*/
|
|
12
|
-
export const CREDITS_PROVIDERS = [
|
|
13
|
-
{ id: 'openai', basePath: 'openai/v1', envVar: 'OPENAI_API_KEY' },
|
|
14
|
-
{ id: 'anthropic', basePath: 'anthropic', envVar: 'ANTHROPIC_API_KEY' },
|
|
15
|
-
{ id: 'deepseek', basePath: 'deepseek/v1', envVar: 'DEEPSEEK_API_KEY' },
|
|
16
|
-
];
|
|
17
|
-
const CREDITS_PROVIDER_IDS = new Set(CREDITS_PROVIDERS.map((provider) => provider.id));
|
|
18
|
-
export function isCreditsProvider(provider) {
|
|
19
|
-
return CREDITS_PROVIDER_IDS.has(provider);
|
|
20
|
-
}
|
|
21
|
-
/** Cloud host injects these when routing LLM calls through OpenBot Credits. */
|
|
22
|
-
export function resolveCreditsAuthConfig() {
|
|
23
|
-
const baseUrl = process.env.OPENBOT_INTEGRATIONS_BASE_URL?.trim();
|
|
24
|
-
const token = process.env.OPENBOT_INTEGRATIONS_TOKEN?.trim();
|
|
25
|
-
if (!baseUrl || !token)
|
|
26
|
-
return undefined;
|
|
27
|
-
return { baseUrl: baseUrl.replace(/\/$/, ''), token };
|
|
28
|
-
}
|
|
29
|
-
export function creditsProviderBaseUrl(config, provider) {
|
|
30
|
-
const match = CREDITS_PROVIDERS.find((entry) => entry.id === provider);
|
|
31
|
-
if (!match) {
|
|
32
|
-
throw new Error(`Unsupported credits provider: ${provider}`);
|
|
33
|
-
}
|
|
34
|
-
return `${config.baseUrl}/${match.basePath}`;
|
|
35
|
-
}
|
|
36
|
-
export function applyCreditsProviders(modelRegistry, authStorage, config) {
|
|
37
|
-
const apiKey = config.token || CREDITS_API_KEY_PLACEHOLDER;
|
|
38
|
-
const headers = { [INTEGRATIONS_TOKEN_HEADER]: config.token };
|
|
39
|
-
for (const provider of CREDITS_PROVIDERS) {
|
|
40
|
-
modelRegistry.registerProvider(provider.id, {
|
|
41
|
-
baseUrl: creditsProviderBaseUrl(config, provider.id),
|
|
42
|
-
headers,
|
|
43
|
-
});
|
|
44
|
-
authStorage.setRuntimeApiKey(provider.id, apiKey);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
export function isCreditsErrorMessage(message) {
|
|
48
|
-
const lower = message.toLowerCase();
|
|
49
|
-
return (lower.includes('insufficient_credits') ||
|
|
50
|
-
lower.includes('insufficient credits') ||
|
|
51
|
-
lower.includes('402'));
|
|
52
|
-
}
|
|
53
|
-
export function isAuthErrorMessage(message) {
|
|
54
|
-
const lower = message.toLowerCase();
|
|
55
|
-
return (lower.includes('api key') ||
|
|
56
|
-
lower.includes('apikey') ||
|
|
57
|
-
lower.includes('401') ||
|
|
58
|
-
lower.includes('unauthorized') ||
|
|
59
|
-
lower.includes('authentication') ||
|
|
60
|
-
lower.includes('not logged in') ||
|
|
61
|
-
lower.includes('login'));
|
|
62
|
-
}
|
|
63
|
-
export function isIntegrationsProviderError(message) {
|
|
64
|
-
const lower = message.toLowerCase();
|
|
65
|
-
return (lower.includes('provider api key not configured') ||
|
|
66
|
-
(lower.includes('503') && lower.includes('provider')));
|
|
67
|
-
}
|
|
68
|
-
export const CREDITS_NOT_CONFIGURED_MESSAGE = 'OpenBot Credits is not configured on this runtime. The cloud host must set OPENBOT_INTEGRATIONS_BASE_URL and OPENBOT_INTEGRATIONS_TOKEN (try redeploying the workspace).';
|
|
69
|
-
export const CREDITS_PROVIDER_UNAVAILABLE_MESSAGE = 'OpenBot Credits could not reach the model provider — the platform provider API key is not configured yet. Try again later or switch this agent to BYOK mode.';
|
|
70
|
-
export const CREDITS_AUTH_FAILED_MESSAGE = 'Pi could not authenticate via OpenBot Credits. Check your workspace credit balance in settings, or switch this agent to BYOK mode.';
|
|
71
|
-
export function creditsErrorMessage(message) {
|
|
72
|
-
if (isIntegrationsProviderError(message)) {
|
|
73
|
-
return CREDITS_PROVIDER_UNAVAILABLE_MESSAGE;
|
|
74
|
-
}
|
|
75
|
-
if (isCreditsErrorMessage(message)) {
|
|
76
|
-
return 'Insufficient workspace credits. Add credits in workspace settings or switch this agent to BYOK mode.';
|
|
77
|
-
}
|
|
78
|
-
if (isAuthErrorMessage(message))
|
|
79
|
-
return CREDITS_AUTH_FAILED_MESSAGE;
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
82
|
-
export function remapPiError(message, authMode) {
|
|
83
|
-
if (authMode !== 'credits')
|
|
84
|
-
return message;
|
|
85
|
-
return creditsErrorMessage(message) ?? message;
|
|
86
|
-
}
|
|
87
|
-
export function unsupportedCreditsProviderMessage(provider) {
|
|
88
|
-
return (`OpenBot Credits does not support the "${provider}" provider. ` +
|
|
89
|
-
'Use openai, anthropic, or deepseek, or switch this agent to BYOK mode.');
|
|
90
|
-
}
|
package/dist/diff.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { buildDiffWidget as runDiffWidget, diffFileFromMutationTool as diffFileFromPiTool, } from '@meetopenbot/plugin-sdk';
|
package/dist/format.js
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
const isAssistantMessage = (message) => message.role === 'assistant';
|
|
2
|
-
export const createStreamState = () => ({
|
|
3
|
-
assistantText: '',
|
|
4
|
-
});
|
|
5
|
-
const strArg = (args, ...keys) => {
|
|
6
|
-
if (!args || typeof args !== 'object')
|
|
7
|
-
return undefined;
|
|
8
|
-
const record = args;
|
|
9
|
-
for (const key of keys) {
|
|
10
|
-
const value = record[key];
|
|
11
|
-
if (typeof value === 'string' && value.trim())
|
|
12
|
-
return value.trim();
|
|
13
|
-
if (typeof value === 'number' && Number.isFinite(value))
|
|
14
|
-
return String(value);
|
|
15
|
-
}
|
|
16
|
-
return undefined;
|
|
17
|
-
};
|
|
18
|
-
const truncate = (text, max = 80) => text.length > max ? `${text.slice(0, max - 3)}…` : text;
|
|
19
|
-
const quoteDetail = (detail) => detail.includes('`') ? `"${detail}"` : `\`${detail}\``;
|
|
20
|
-
const formatToolDetail = (toolName, args) => {
|
|
21
|
-
switch (toolName) {
|
|
22
|
-
case 'bash': {
|
|
23
|
-
const command = strArg(args, 'command');
|
|
24
|
-
return command ? truncate(command.replace(/\s+/g, ' ')) : undefined;
|
|
25
|
-
}
|
|
26
|
-
case 'read':
|
|
27
|
-
case 'edit':
|
|
28
|
-
case 'write': {
|
|
29
|
-
const path = strArg(args, 'path', 'file_path');
|
|
30
|
-
if (!path)
|
|
31
|
-
return undefined;
|
|
32
|
-
let detail = truncate(path, 120);
|
|
33
|
-
if (toolName === 'read') {
|
|
34
|
-
const offset = strArg(args, 'offset');
|
|
35
|
-
const limit = strArg(args, 'limit');
|
|
36
|
-
const parts = [offset && `offset=${offset}`, limit && `limit=${limit}`].filter(Boolean);
|
|
37
|
-
if (parts.length)
|
|
38
|
-
detail += ` (${parts.join(', ')})`;
|
|
39
|
-
}
|
|
40
|
-
return detail;
|
|
41
|
-
}
|
|
42
|
-
case 'grep': {
|
|
43
|
-
const pattern = strArg(args, 'pattern');
|
|
44
|
-
const path = strArg(args, 'path') ?? '.';
|
|
45
|
-
if (!pattern)
|
|
46
|
-
return undefined;
|
|
47
|
-
return `${truncate(pattern)} in ${truncate(path, 60)}`;
|
|
48
|
-
}
|
|
49
|
-
case 'find': {
|
|
50
|
-
const pattern = strArg(args, 'pattern');
|
|
51
|
-
const path = strArg(args, 'path') ?? '.';
|
|
52
|
-
if (!pattern)
|
|
53
|
-
return undefined;
|
|
54
|
-
return `${truncate(pattern)} in ${truncate(path, 60)}`;
|
|
55
|
-
}
|
|
56
|
-
case 'ls': {
|
|
57
|
-
const path = strArg(args, 'path') ?? '.';
|
|
58
|
-
return truncate(path, 120);
|
|
59
|
-
}
|
|
60
|
-
default:
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
const formatToolStart = (toolName, args) => {
|
|
65
|
-
const detail = formatToolDetail(toolName, args);
|
|
66
|
-
if (detail)
|
|
67
|
-
return `Running **${toolName}** (${quoteDetail(detail)})…`;
|
|
68
|
-
return `Running **${toolName}**…`;
|
|
69
|
-
};
|
|
70
|
-
/** Map Pi session events to user-visible output chunks. */
|
|
71
|
-
export const formatPiEvent = (event, state) => {
|
|
72
|
-
switch (event.type) {
|
|
73
|
-
case 'message_update': {
|
|
74
|
-
const assistantEvent = event.assistantMessageEvent;
|
|
75
|
-
if (assistantEvent.type !== 'text_delta')
|
|
76
|
-
return undefined;
|
|
77
|
-
state.assistantText += assistantEvent.delta;
|
|
78
|
-
return undefined;
|
|
79
|
-
}
|
|
80
|
-
case 'tool_execution_start': {
|
|
81
|
-
const statusLine = formatToolStart(event.toolName, event.args);
|
|
82
|
-
return {
|
|
83
|
-
type: 'tool_start',
|
|
84
|
-
toolCallId: event.toolCallId,
|
|
85
|
-
toolName: event.toolName,
|
|
86
|
-
args: event.args,
|
|
87
|
-
statusLine,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
case 'tool_execution_end': {
|
|
91
|
-
let statusLine;
|
|
92
|
-
if (event.isError) {
|
|
93
|
-
statusLine = `Tool **${event.toolName}** failed.`;
|
|
94
|
-
}
|
|
95
|
-
return {
|
|
96
|
-
type: 'tool_end',
|
|
97
|
-
toolCallId: event.toolCallId,
|
|
98
|
-
toolName: event.toolName,
|
|
99
|
-
result: event.result,
|
|
100
|
-
isError: event.isError,
|
|
101
|
-
statusLine,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
case 'auto_retry_start': {
|
|
105
|
-
const statusLine = `Retrying (${event.attempt}/${event.maxAttempts})…`;
|
|
106
|
-
return { type: 'status', statusLine };
|
|
107
|
-
}
|
|
108
|
-
case 'compaction_start': {
|
|
109
|
-
const statusLine = 'Compacting conversation context…';
|
|
110
|
-
return { type: 'status', statusLine };
|
|
111
|
-
}
|
|
112
|
-
case 'agent_end': {
|
|
113
|
-
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
|
|
114
|
-
if (lastAssistant?.stopReason === 'error' && lastAssistant.errorMessage?.trim()) {
|
|
115
|
-
return { type: 'text', content: `**Pi error:** ${lastAssistant.errorMessage.trim()}` };
|
|
116
|
-
}
|
|
117
|
-
if (state.assistantText.trim()) {
|
|
118
|
-
return { type: 'text', content: state.assistantText };
|
|
119
|
-
}
|
|
120
|
-
return undefined;
|
|
121
|
-
}
|
|
122
|
-
default:
|
|
123
|
-
return undefined;
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
export const formatPiError = (error) => {
|
|
127
|
-
if (error instanceof Error)
|
|
128
|
-
return error.message;
|
|
129
|
-
return String(error);
|
|
130
|
-
};
|
|
131
|
-
export const formatToolResult = (result) => {
|
|
132
|
-
if (!result)
|
|
133
|
-
return '';
|
|
134
|
-
if (typeof result === 'string')
|
|
135
|
-
return result;
|
|
136
|
-
if (result && typeof result === 'object' && Array.isArray(result.content)) {
|
|
137
|
-
return result.content
|
|
138
|
-
.map((c) => {
|
|
139
|
-
if (c.type === 'text')
|
|
140
|
-
return c.text;
|
|
141
|
-
if (c.type === 'image')
|
|
142
|
-
return `[Image: ${c.source?.data?.slice(0, 20)}...]`;
|
|
143
|
-
return `[${c.type}]`;
|
|
144
|
-
})
|
|
145
|
-
.join('\n');
|
|
146
|
-
}
|
|
147
|
-
return JSON.stringify(result, null, 2);
|
|
148
|
-
};
|