@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/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
+ }