@nevescloud/pip 3.8.3 → 3.9.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/README.md CHANGED
@@ -210,4 +210,4 @@ For a turn loop, tool dispatch, history, and an Anthropic provider, pair pip-cor
210
210
 
211
211
  ## Demo
212
212
 
213
- `docs/index.html` is a standalone demo wiring two stub providers (echo + reverse) through the runtime. Open it locally to play with the slash autocomplete, `/model` switching, and the chat shell without needing an API key.
213
+ `docs/index.html` is a standalone demo wiring three stub providers (`echo`, `reverse`, `danger`) through the runtime. Open it locally to play with the slash autocomplete, `/model` switching, and the chat shell without needing an API key. The `danger` stub fires a `delete_thing` tool_use that's gated by a `preToolUse` hook (Run/Cancel via `askInChat`) — a working example of the runtime's hook events.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nevescloud/pip",
3
- "version": "3.8.3",
3
+ "version": "3.9.1",
4
4
  "description": "Floating assistant bubble + panel + chat runtime. ESM, no build.",
5
5
  "type": "module",
6
6
  "main": "pip-core.esm.js",
@@ -257,9 +257,21 @@ export function createTransformersRenderer() {
257
257
  return splitThinking(buffer).answer || buffer.trim();
258
258
  }
259
259
 
260
+ // Background warm — starts the transformers.js import + tokenizer +
261
+ // model download without running inference. Hosts call this from
262
+ // requestIdleCallback after their page boots so the first user prompt
263
+ // doesn't pay the cold-start cost. Safe to call repeatedly; ensureLoaded
264
+ // is internally idempotent (returns the in-flight loadingPromise).
265
+ async function warm(turnEl) {
266
+ if (!config?.id) return;
267
+ try { await ensureLoaded(turnEl); }
268
+ catch { /* swallow — first real generate() will re-throw with the same error */ }
269
+ }
270
+
260
271
  return {
261
272
  setModel,
262
273
  generate,
274
+ warm,
263
275
  get currentModelId() { return config?.id || null; },
264
276
  };
265
277
  }
@@ -295,7 +307,7 @@ export function local({
295
307
  const renderer = createTransformersRenderer();
296
308
  if (model) renderer.setModel({ id: model, dtype, maxTokens, genParams, chatTemplate });
297
309
 
298
- return ({ messages, signal, system, tools, turnEl, setReplyText }) => (async function* () {
310
+ const provider = ({ messages, signal, system, tools, turnEl, setReplyText }) => (async function* () {
299
311
  const effectiveSystem = system || systemPrompt || '';
300
312
  const augmentedSystem = buildToolSystemPrompt(effectiveSystem, tools);
301
313
 
@@ -347,6 +359,13 @@ export function local({
347
359
  if (error) throw error;
348
360
  yield { type: 'turn_end', stopReason: sawToolUse ? 'tool_use' : 'end_turn' };
349
361
  })();
362
+
363
+ // Surface the renderer's background warm() on the provider so hosts
364
+ // can trigger transformers.js import + weight download in idle time
365
+ // ahead of the first user message. Idempotent; no-op until setModel
366
+ // has run (covered above when `model` is passed).
367
+ provider.warm = (turnEl) => renderer.warm(turnEl);
368
+ return provider;
350
369
  }
351
370
 
352
371
  export { splitThinking };
package/runtime.esm.js CHANGED
@@ -51,6 +51,24 @@ export function createRuntime({
51
51
  }
52
52
  }
53
53
 
54
+ // Hook-style emit: awaits each handler in registration order and returns
55
+ // the first non-undefined return value. Lets host code on `preTurn` and
56
+ // `preToolUse` block, defer (e.g. await an askInChat approval), or mutate
57
+ // the action without a separate `hook(...)` channel.
58
+ async function emitAsync(name, payload) {
59
+ const set = listeners.get(name);
60
+ if (!set) return undefined;
61
+ for (const fn of set) {
62
+ try {
63
+ const ret = await fn(payload);
64
+ if (ret !== undefined) return ret;
65
+ } catch (e) {
66
+ console.warn(`[pip-runtime] handler for "${name}" threw`, e);
67
+ }
68
+ }
69
+ return undefined;
70
+ }
71
+
54
72
  function on(name, handler) {
55
73
  if (!listeners.has(name)) listeners.set(name, new Set());
56
74
  listeners.get(name).add(handler);
@@ -111,6 +129,26 @@ export function createRuntime({
111
129
  abortCtrl = new AbortController();
112
130
  emit('turnStart', { turnEl, userText });
113
131
 
132
+ // `added` snapshot: anything pushed after this index was produced by
133
+ // *this* turn (assistant messages + tool roundtrips). The user message
134
+ // was pushed in onSubmit before we entered, so it's excluded — hosts
135
+ // already have it in payload.userText.
136
+ const startIdx = messages.length;
137
+
138
+ // preTurn fires once per user message, after history is sealed and
139
+ // before any provider call. Handler can return { system } to override
140
+ // the system prompt for this turn loop — the use case is per-turn RAG
141
+ // injection that depends on userText. Returning undefined falls through
142
+ // to the configured systemFn().
143
+ const baseSystem = systemFn();
144
+ const preTurn = await emitAsync('preTurn', {
145
+ turnEl,
146
+ userText,
147
+ messages: messages.slice(),
148
+ system: baseSystem,
149
+ });
150
+ const turnSystem = preTurn?.system !== undefined ? preTurn.system : baseSystem;
151
+
114
152
  let buffer = '';
115
153
  let lastStopReason = null;
116
154
 
@@ -119,7 +157,7 @@ export function createRuntime({
119
157
  const stream = currentProvider({
120
158
  messages: messages.slice(),
121
159
  tools: buildToolList(),
122
- system: systemFn(),
160
+ system: turnSystem,
123
161
  signal: abortCtrl.signal,
124
162
  // turnEl + setReplyText let renderer-shaped providers (e.g. the
125
163
  // local transformers wrapper) paint progress bars and <think>
@@ -176,13 +214,34 @@ export function createRuntime({
176
214
  emit('toolResult', { turnEl, name: tu.name, ok: false, error: err, id: tu.id });
177
215
  continue;
178
216
  }
217
+
218
+ // preToolUse fires per tool_use before the handler runs. Handler
219
+ // can return { approve: false, reason } to short-circuit (useful
220
+ // for human-in-the-loop gating via askInChat) or { input } to
221
+ // mutate the args. Falling through preserves the original call.
222
+ const directive = await emitAsync('preToolUse', {
223
+ turnEl, name: tu.name, input: tu.input, id: tu.id,
224
+ });
225
+ if (directive?.approve === false) {
226
+ const reason = directive.reason || `Denied by preToolUse hook`;
227
+ toolResults.push({
228
+ type: 'tool_result',
229
+ tool_use_id: tu.id,
230
+ content: reason,
231
+ is_error: true,
232
+ });
233
+ emit('toolResult', { turnEl, name: tu.name, ok: false, error: reason, id: tu.id });
234
+ continue;
235
+ }
236
+ const effectiveInput = directive?.input !== undefined ? directive.input : tu.input;
237
+
179
238
  try {
180
239
  const ctx = {
181
240
  signal: abortCtrl.signal,
182
241
  turnId: tu.id,
183
242
  runtime: handle,
184
243
  };
185
- const result = await tool.handler(tu.input, ctx);
244
+ const result = await tool.handler(effectiveInput, ctx);
186
245
  const content = typeof result === 'string' ? result : JSON.stringify(result);
187
246
  toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content });
188
247
  emit('toolResult', { turnEl, name: tu.name, ok: true, result, id: tu.id });
@@ -202,6 +261,13 @@ export function createRuntime({
202
261
 
203
262
  trim();
204
263
  persist();
264
+ emit('postTurn', {
265
+ turnEl,
266
+ userText,
267
+ added: messages.slice(startIdx),
268
+ text: buffer.trim(),
269
+ stopReason: lastStopReason,
270
+ });
205
271
  return buffer.trim();
206
272
  } catch (err) {
207
273
  if (err?.name === 'AbortError') {