@nevescloud/pip 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/package.json +44 -0
- package/pip-core.esm.js +2210 -0
- package/providers/anthropic.esm.js +120 -0
- package/providers/openai.esm.js +164 -0
- package/providers/transformers.esm.js +273 -0
- package/runtime.esm.js +386 -0
package/runtime.esm.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// pip-runtime — turn loop, tool dispatch, history, slash commands.
|
|
2
|
+
// Plug into pip-core via createPip({ onSubmit: rt.onSubmit, onSlash: rt.onSlash }).
|
|
3
|
+
// See docs/RUNTIME.md for the full design.
|
|
4
|
+
|
|
5
|
+
const DEFAULT_MAX_TURNS = 5;
|
|
6
|
+
const DEFAULT_HISTORY_LIMIT = 20;
|
|
7
|
+
|
|
8
|
+
export function createRuntime({
|
|
9
|
+
provider,
|
|
10
|
+
models = [],
|
|
11
|
+
systemPrompt = '',
|
|
12
|
+
tools = [],
|
|
13
|
+
maxTurns = DEFAULT_MAX_TURNS,
|
|
14
|
+
historyLimit = DEFAULT_HISTORY_LIMIT,
|
|
15
|
+
historyKey = null,
|
|
16
|
+
preprocess = null,
|
|
17
|
+
} = {}) {
|
|
18
|
+
if (!provider && !models.length) {
|
|
19
|
+
throw new Error('createRuntime: `provider` or `models` is required');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const systemFn = typeof systemPrompt === 'function' ? systemPrompt : () => systemPrompt;
|
|
23
|
+
const registeredTools = new Map();
|
|
24
|
+
for (const t of tools) registeredTools.set(t.name, t);
|
|
25
|
+
const registeredSlash = new Map();
|
|
26
|
+
|
|
27
|
+
let messages = [];
|
|
28
|
+
let currentProvider = provider || models[0].provider;
|
|
29
|
+
// If `provider` was passed explicitly and matches one of the models by
|
|
30
|
+
// reference, surface its name as current. Otherwise the first model wins.
|
|
31
|
+
let currentModelName = provider
|
|
32
|
+
? (models.find((m) => m.provider === provider)?.name || null)
|
|
33
|
+
: (models[0]?.name || null);
|
|
34
|
+
let abortCtrl = null;
|
|
35
|
+
const listeners = new Map();
|
|
36
|
+
|
|
37
|
+
if (historyKey) {
|
|
38
|
+
try {
|
|
39
|
+
const saved = localStorage.getItem(historyKey);
|
|
40
|
+
if (saved) messages = JSON.parse(saved);
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function emit(name, payload) {
|
|
45
|
+
const set = listeners.get(name);
|
|
46
|
+
if (!set) return;
|
|
47
|
+
for (const fn of set) {
|
|
48
|
+
try { fn(payload); } catch (e) {
|
|
49
|
+
console.warn(`[pip-runtime] handler for "${name}" threw`, e);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function on(name, handler) {
|
|
55
|
+
if (!listeners.has(name)) listeners.set(name, new Set());
|
|
56
|
+
listeners.get(name).add(handler);
|
|
57
|
+
return () => listeners.get(name).delete(handler);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function persist() {
|
|
61
|
+
if (!historyKey) return;
|
|
62
|
+
try { localStorage.setItem(historyKey, JSON.stringify(messages)); } catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function trim() {
|
|
66
|
+
// Cap by index, not by role count — assistant tool turns and user
|
|
67
|
+
// tool_result turns are part of the same compound exchange.
|
|
68
|
+
const cap = historyLimit * 4;
|
|
69
|
+
if (messages.length > cap) messages = messages.slice(-cap);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildToolList() {
|
|
73
|
+
return Array.from(registeredTools.values()).map((t) => ({
|
|
74
|
+
name: t.name,
|
|
75
|
+
description: t.description,
|
|
76
|
+
schema: t.schema || t.input_schema,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function onSubmit(text, pipCtx) {
|
|
81
|
+
let processed = text;
|
|
82
|
+
if (preprocess) {
|
|
83
|
+
try { processed = await preprocess(text, pipCtx); }
|
|
84
|
+
catch (e) {
|
|
85
|
+
console.warn('[pip-runtime] preprocess threw, using raw text', e);
|
|
86
|
+
processed = text;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
messages.push({ role: 'user', content: processed });
|
|
90
|
+
return runTurnLoop(pipCtx, processed);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function regenerate(pipCtx) {
|
|
94
|
+
// Drop trailing assistant turns and tool exchanges until we're back at a
|
|
95
|
+
// user-text turn, then re-run from there.
|
|
96
|
+
while (messages.length > 0) {
|
|
97
|
+
const last = messages[messages.length - 1];
|
|
98
|
+
if (last.role === 'user' && typeof last.content === 'string') break;
|
|
99
|
+
messages.pop();
|
|
100
|
+
}
|
|
101
|
+
if (messages.length === 0) {
|
|
102
|
+
throw new Error('regenerate: no user turn in history');
|
|
103
|
+
}
|
|
104
|
+
return runTurnLoop(pipCtx, messages[messages.length - 1].content);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function runTurnLoop(pipCtx, userText) {
|
|
108
|
+
const turnEl = pipCtx?.turnEl;
|
|
109
|
+
const setReplyText = pipCtx?.setReplyText;
|
|
110
|
+
|
|
111
|
+
abortCtrl = new AbortController();
|
|
112
|
+
emit('turnStart', { turnEl, userText });
|
|
113
|
+
|
|
114
|
+
let buffer = '';
|
|
115
|
+
let lastStopReason = null;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
119
|
+
const stream = currentProvider({
|
|
120
|
+
messages: messages.slice(),
|
|
121
|
+
tools: buildToolList(),
|
|
122
|
+
system: systemFn(),
|
|
123
|
+
signal: abortCtrl.signal,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const assistantContent = [];
|
|
127
|
+
let textPart = '';
|
|
128
|
+
|
|
129
|
+
for await (const ev of stream) {
|
|
130
|
+
if (ev.type === 'text_delta') {
|
|
131
|
+
textPart += ev.text;
|
|
132
|
+
buffer += ev.text;
|
|
133
|
+
if (setReplyText && turnEl) setReplyText(turnEl, buffer, true);
|
|
134
|
+
emit('text_delta', { turnEl, delta: ev.text, full: buffer });
|
|
135
|
+
} else if (ev.type === 'tool_use') {
|
|
136
|
+
if (textPart) {
|
|
137
|
+
assistantContent.push({ type: 'text', text: textPart });
|
|
138
|
+
textPart = '';
|
|
139
|
+
}
|
|
140
|
+
assistantContent.push({ type: 'tool_use', id: ev.id, name: ev.name, input: ev.input });
|
|
141
|
+
emit('toolCall', { turnEl, name: ev.name, input: ev.input, id: ev.id });
|
|
142
|
+
} else if (ev.type === 'turn_end') {
|
|
143
|
+
if (textPart) {
|
|
144
|
+
assistantContent.push({ type: 'text', text: textPart });
|
|
145
|
+
textPart = '';
|
|
146
|
+
}
|
|
147
|
+
lastStopReason = ev.stopReason;
|
|
148
|
+
emit('turnEnd', { turnEl, stopReason: ev.stopReason, usage: ev.usage });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (assistantContent.length) {
|
|
153
|
+
messages.push({ role: 'assistant', content: assistantContent });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (lastStopReason !== 'tool_use') break;
|
|
157
|
+
|
|
158
|
+
const toolUses = assistantContent.filter((b) => b.type === 'tool_use');
|
|
159
|
+
const toolResults = [];
|
|
160
|
+
for (const tu of toolUses) {
|
|
161
|
+
const tool = registeredTools.get(tu.name);
|
|
162
|
+
if (!tool) {
|
|
163
|
+
const err = `Tool "${tu.name}" not registered`;
|
|
164
|
+
toolResults.push({
|
|
165
|
+
type: 'tool_result',
|
|
166
|
+
tool_use_id: tu.id,
|
|
167
|
+
content: err,
|
|
168
|
+
is_error: true,
|
|
169
|
+
});
|
|
170
|
+
emit('toolResult', { turnEl, name: tu.name, ok: false, error: err, id: tu.id });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const ctx = {
|
|
175
|
+
signal: abortCtrl.signal,
|
|
176
|
+
turnId: tu.id,
|
|
177
|
+
runtime: handle,
|
|
178
|
+
};
|
|
179
|
+
const result = await tool.handler(tu.input, ctx);
|
|
180
|
+
const content = typeof result === 'string' ? result : JSON.stringify(result);
|
|
181
|
+
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content });
|
|
182
|
+
emit('toolResult', { turnEl, name: tu.name, ok: true, result, id: tu.id });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
const msg = err && err.message ? err.message : String(err);
|
|
185
|
+
toolResults.push({
|
|
186
|
+
type: 'tool_result',
|
|
187
|
+
tool_use_id: tu.id,
|
|
188
|
+
content: msg,
|
|
189
|
+
is_error: true,
|
|
190
|
+
});
|
|
191
|
+
emit('toolResult', { turnEl, name: tu.name, ok: false, error: err, id: tu.id });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
messages.push({ role: 'user', content: toolResults });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
trim();
|
|
198
|
+
persist();
|
|
199
|
+
return buffer.trim();
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err?.name === 'AbortError') {
|
|
202
|
+
emit('error', { turnEl, error: err });
|
|
203
|
+
return buffer.trim() || '(cancelled)';
|
|
204
|
+
}
|
|
205
|
+
emit('error', { turnEl, error: err });
|
|
206
|
+
throw err;
|
|
207
|
+
} finally {
|
|
208
|
+
abortCtrl = null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Registry-first dispatch. Host-registered commands win over built-ins
|
|
213
|
+
// so `/help` can be overridden if a host wants different formatting.
|
|
214
|
+
// Built-in `/help` enumerates both layers so users see one combined list.
|
|
215
|
+
function onSlash(text) {
|
|
216
|
+
const slice = text.slice(1);
|
|
217
|
+
const sp = slice.indexOf(' ');
|
|
218
|
+
const cmdRaw = sp === -1 ? slice : slice.slice(0, sp);
|
|
219
|
+
const args = sp === -1 ? '' : slice.slice(sp + 1);
|
|
220
|
+
const cmd = cmdRaw.toLowerCase();
|
|
221
|
+
|
|
222
|
+
const registered = registeredSlash.get(cmd);
|
|
223
|
+
if (registered) {
|
|
224
|
+
try { return registered.handler(args) ?? null; }
|
|
225
|
+
catch (e) { return { reply: `\`/${cmd}\` failed: ${e.message || e}` }; }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (cmd === 'clear' || cmd === 'reset') {
|
|
229
|
+
messages = [];
|
|
230
|
+
persist();
|
|
231
|
+
return { clearedUI: true };
|
|
232
|
+
}
|
|
233
|
+
if (cmd === 'tools') {
|
|
234
|
+
const list = Array.from(registeredTools.values())
|
|
235
|
+
.map((t) => `\`${t.name}\` — ${t.description || ''}`)
|
|
236
|
+
.join('\n');
|
|
237
|
+
return { reply: list ? '**Tools:**\n' + list : 'No tools registered.' };
|
|
238
|
+
}
|
|
239
|
+
if (cmd === 'model' && models.length) {
|
|
240
|
+
const arg = args.trim();
|
|
241
|
+
if (!arg) {
|
|
242
|
+
const cur = currentModelName ? `\`${currentModelName}\`` : '(unknown)';
|
|
243
|
+
const others = models
|
|
244
|
+
.filter((m) => m.name !== currentModelName)
|
|
245
|
+
.map((m) => `\`${m.name}\``);
|
|
246
|
+
const tail = others.length ? ` Switch with \`/model <name>\` — try ${others.join(', ')}.` : '';
|
|
247
|
+
return { reply: `Current model: ${cur}.${tail}` };
|
|
248
|
+
}
|
|
249
|
+
const target = models.find((m) => m.name.toLowerCase() === arg.toLowerCase());
|
|
250
|
+
if (!target) {
|
|
251
|
+
const all = models.map((m) => `\`${m.name}\``).join(', ');
|
|
252
|
+
return { reply: `Unknown model \`${arg}\`. One of: ${all}` };
|
|
253
|
+
}
|
|
254
|
+
setModel(target.name);
|
|
255
|
+
const suffix = target.label ? ` (${target.label})` : '';
|
|
256
|
+
return { reply: `Switched to \`${target.name}\`${suffix}.` };
|
|
257
|
+
}
|
|
258
|
+
if (cmd === 'help' || cmd === '?') {
|
|
259
|
+
const lines = ['**Commands:**'];
|
|
260
|
+
for (const s of registeredSlash.values()) {
|
|
261
|
+
lines.push(`- \`/${s.name}\` — ${s.description || ''}`);
|
|
262
|
+
}
|
|
263
|
+
lines.push('- `/clear` — reset conversation');
|
|
264
|
+
lines.push('- `/tools` — list registered tools');
|
|
265
|
+
if (models.length) lines.push('- `/model` — list or switch active model');
|
|
266
|
+
lines.push('- `/help` — this list');
|
|
267
|
+
return { reply: lines.join('\n') };
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function registerTool(t) {
|
|
273
|
+
if (!t || !t.name || typeof t.handler !== 'function') {
|
|
274
|
+
throw new Error('registerTool: { name, handler } required');
|
|
275
|
+
}
|
|
276
|
+
registeredTools.set(t.name, t);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function unregisterTool(name) {
|
|
280
|
+
registeredTools.delete(name);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Slash commands. Same shape as registerTool: name + handler are required;
|
|
284
|
+
// description and complete() are surfaced by autocomplete + /help.
|
|
285
|
+
// handler(argsString) returns { reply?, clearedUI?, passThrough? } | null.
|
|
286
|
+
function registerSlash(s) {
|
|
287
|
+
if (!s || !s.name || typeof s.handler !== 'function') {
|
|
288
|
+
throw new Error('registerSlash: { name, handler } required');
|
|
289
|
+
}
|
|
290
|
+
registeredSlash.set(s.name.toLowerCase(), s);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function unregisterSlash(name) {
|
|
294
|
+
registeredSlash.delete(String(name).toLowerCase());
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Source for pip-core's autocomplete dropdown. Combines registered +
|
|
298
|
+
// built-ins so users see one merged list as they type. /model is only
|
|
299
|
+
// surfaced when the host configured a model registry.
|
|
300
|
+
function builtinSlash() {
|
|
301
|
+
const out = [
|
|
302
|
+
{ name: 'clear', description: 'reset conversation' },
|
|
303
|
+
{ name: 'tools', description: 'list registered tools' },
|
|
304
|
+
];
|
|
305
|
+
if (models.length) {
|
|
306
|
+
out.push({
|
|
307
|
+
name: 'model',
|
|
308
|
+
description: 'list or switch active model',
|
|
309
|
+
complete: (partial) => models
|
|
310
|
+
.map((m) => m.name)
|
|
311
|
+
.filter((n) => n.toLowerCase().startsWith(partial.toLowerCase())),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
out.push({ name: 'help', description: 'list commands' });
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
function slashSource() {
|
|
318
|
+
const out = [];
|
|
319
|
+
for (const s of registeredSlash.values()) out.push({ name: s.name, description: s.description, complete: s.complete });
|
|
320
|
+
for (const b of builtinSlash()) if (!registeredSlash.has(b.name)) out.push(b);
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function setProvider(p) {
|
|
325
|
+
currentProvider = p;
|
|
326
|
+
// A bare setProvider that doesn't pass through the model registry
|
|
327
|
+
// can't keep currentModelName in sync — clear it so /model bare
|
|
328
|
+
// doesn't lie about what's active.
|
|
329
|
+
currentModelName = models.find((m) => m.provider === p)?.name || null;
|
|
330
|
+
messages = [];
|
|
331
|
+
persist();
|
|
332
|
+
emit('providerSwitched', {
|
|
333
|
+
provider: p,
|
|
334
|
+
name: currentModelName,
|
|
335
|
+
label: currentModelName ? models.find((m) => m.name === currentModelName)?.label : undefined,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function setModel(name) {
|
|
340
|
+
const m = models.find((x) => x.name === name);
|
|
341
|
+
if (!m) throw new Error(`setModel: unknown model "${name}"`);
|
|
342
|
+
currentProvider = m.provider;
|
|
343
|
+
currentModelName = m.name;
|
|
344
|
+
messages = [];
|
|
345
|
+
persist();
|
|
346
|
+
emit('providerSwitched', { provider: m.provider, name: m.name, label: m.label });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function cancel() {
|
|
350
|
+
if (abortCtrl) abortCtrl.abort();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const handle = {
|
|
354
|
+
onSubmit,
|
|
355
|
+
onSlash,
|
|
356
|
+
slashSource,
|
|
357
|
+
regenerate,
|
|
358
|
+
registerTool,
|
|
359
|
+
unregisterTool,
|
|
360
|
+
registerSlash,
|
|
361
|
+
unregisterSlash,
|
|
362
|
+
setProvider,
|
|
363
|
+
setModel,
|
|
364
|
+
get currentModel() {
|
|
365
|
+
if (!currentModelName) return null;
|
|
366
|
+
const m = models.find((x) => x.name === currentModelName);
|
|
367
|
+
return m ? { name: m.name, label: m.label } : null;
|
|
368
|
+
},
|
|
369
|
+
cancel,
|
|
370
|
+
on,
|
|
371
|
+
history: {
|
|
372
|
+
snapshot: () => messages.slice(),
|
|
373
|
+
clear: () => { messages = []; persist(); },
|
|
374
|
+
truncate: (index) => {
|
|
375
|
+
if (typeof index !== 'number' || index < 0) return;
|
|
376
|
+
if (index < messages.length) {
|
|
377
|
+
messages.length = index;
|
|
378
|
+
persist();
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
get length() { return messages.length; },
|
|
382
|
+
get limit() { return historyLimit; },
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
return handle;
|
|
386
|
+
}
|