bloby-bot 0.47.6 → 0.47.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.47.6",
3
+ "version": "0.47.8",
4
4
  "releaseNotes": [
5
5
  "1. # voice note (PTT bubble)",
6
6
  "2. # audio file + caption",
@@ -9,6 +9,12 @@
9
9
  export interface AsyncQueue<T> extends AsyncIterable<T> {
10
10
  push(item: T): void;
11
11
  end(): void;
12
+ /**
13
+ * Non-blocking drain: return every item currently buffered without waiting
14
+ * for a new one. Used by the session to fold mid-turn user messages into
15
+ * the model's history without breaking the outer `for await` consumer.
16
+ */
17
+ drainPending(): T[];
12
18
  }
13
19
 
14
20
  export function createAsyncQueue<T>(): AsyncQueue<T> {
@@ -30,6 +36,10 @@ export function createAsyncQueue<T>(): AsyncQueue<T> {
30
36
  done = true;
31
37
  if (resolve) resolve({ value: undefined as any, done: true });
32
38
  },
39
+ drainPending() {
40
+ if (pending.length === 0) return [];
41
+ return pending.splice(0, pending.length);
42
+ },
33
43
  [Symbol.asyncIterator]() {
34
44
  return {
35
45
  next(): Promise<IteratorResult<T>> {
@@ -82,6 +82,26 @@ function formatConversationHistory(messages: RecentMessage[]): string {
82
82
  return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n');
83
83
  }
84
84
 
85
+ /**
86
+ * Live-conversation pacing hint. The Claude Agent SDK trains its model to do
87
+ * this natively; non-Anthropic models (Gemini especially) tend to go silent
88
+ * during tool loops and never report progress. This nudge makes the
89
+ * conversation feel alive — quick acknowledgement before long tasks, short
90
+ * status notes between tool calls, and inline answers if the user types
91
+ * something while the agent is mid-task.
92
+ */
93
+ const LIVE_CONVERSATION_HINT = `
94
+
95
+ ---
96
+ # Live-conversation pacing
97
+
98
+ You are running in a streaming chat where the user can keep typing while you work. Make the conversation feel alive:
99
+
100
+ - Before kicking off a multi-step task, say one short line acknowledging it ("On it, looking at the widget now.").
101
+ - Between tool calls on long tasks, drop a brief progress note ("Found the file, checking the layout next.") so the user knows you're still working.
102
+ - If a new user message arrives while you're mid-task, you'll see it as a fresh user-role message in the conversation history. Answer it briefly inline, mention you're still working on the main task, then continue.
103
+ - Final answers should be concise and concrete.`;
104
+
85
105
  async function buildSystemPrompt(
86
106
  names?: { botName: string; humanName: string },
87
107
  recentMessages?: RecentMessage[],
@@ -89,6 +109,7 @@ async function buildSystemPrompt(
89
109
  const memoryFiles = readMemoryFiles();
90
110
  const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
91
111
  let systemPrompt = basePrompt;
112
+ systemPrompt += LIVE_CONVERSATION_HINT;
92
113
  systemPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
93
114
 
94
115
  try {
@@ -100,8 +100,12 @@ function toGeminiParts(content: PiContentBlock[]): any[] {
100
100
  } else if (b.type === 'image') {
101
101
  parts.push({ inlineData: { mimeType: b.mediaType, data: b.data } });
102
102
  } else if (b.type === 'tool_use') {
103
- // Assistant turn: the model asked to invoke a tool.
104
- parts.push({ functionCall: { name: b.name, args: b.input || {} } });
103
+ // Assistant turn: the model asked to invoke a tool. Thinking-capable
104
+ // Gemini 3.x rejects (HTTP 400) any echoed functionCall whose
105
+ // thoughtSignature is missing, so we forward it verbatim when present.
106
+ const part: any = { functionCall: { name: b.name, args: b.input || {} } };
107
+ if (b.thoughtSignature) part.thoughtSignature = b.thoughtSignature;
108
+ parts.push(part);
105
109
  } else if (b.type === 'tool_result') {
106
110
  // Function responses can be strings, objects, or even error markers.
107
111
  // Wrap text in `{ output: ... }` (Gemini's docs use a flexible
@@ -262,6 +266,9 @@ export async function* streamGoogle(req: PiStreamRequest): AsyncIterable<PiStrea
262
266
  id,
263
267
  name: part.functionCall.name,
264
268
  input: part.functionCall.args || {},
269
+ // Thinking-capable models attach a signature that we must echo
270
+ // back unchanged on the next turn. Optional on non-thinking models.
271
+ thoughtSignature: typeof part.thoughtSignature === 'string' ? part.thoughtSignature : undefined,
265
272
  };
266
273
  continue;
267
274
  }
@@ -17,7 +17,10 @@ export type PiRole = 'user' | 'assistant' | 'tool';
17
17
  export type PiContentBlock =
18
18
  | { type: 'text'; text: string }
19
19
  | { type: 'image'; mediaType: string; data: string } // base64
20
- | { type: 'tool_use'; id: string; name: string; input: any }
20
+ // `thoughtSignature` is a Gemini 3.x thinking-model field. Pi-flavored
21
+ // providers that emit reasoning attach it to function-call parts; the API
22
+ // rejects the next turn with HTTP 400 if we don't echo it back verbatim.
23
+ | { type: 'tool_use'; id: string; name: string; input: any; thoughtSignature?: string }
21
24
  | { type: 'tool_result'; toolUseId: string; content: string; isError?: boolean };
22
25
 
23
26
  export interface PiMessage {
@@ -50,7 +53,7 @@ export type PiStopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'error' | 'a
50
53
  export type PiStreamEvent =
51
54
  | { type: 'text_delta'; delta: string }
52
55
  | { type: 'text_end'; text: string }
53
- | { type: 'tool_use'; id: string; name: string; input: any }
56
+ | { type: 'tool_use'; id: string; name: string; input: any; thoughtSignature?: string }
54
57
  | { type: 'done'; stopReason: PiStopReason; usage?: PiUsage }
55
58
  | { type: 'error'; error: string };
56
59
 
@@ -68,7 +68,7 @@ export function createPiSession(init: PiSessionInit): PiSession {
68
68
  /** One stream round — collect the assistant blocks the model emits this pass. */
69
69
  interface RoundResult {
70
70
  text: string;
71
- toolUses: { id: string; name: string; input: any }[];
71
+ toolUses: { id: string; name: string; input: any; thoughtSignature?: string }[];
72
72
  errored: boolean;
73
73
  }
74
74
 
@@ -100,7 +100,12 @@ export function createPiSession(init: PiSessionInit): PiSession {
100
100
  result.text = evt.text;
101
101
  break;
102
102
  case 'tool_use':
103
- result.toolUses.push({ id: evt.id, name: evt.name, input: evt.input });
103
+ result.toolUses.push({
104
+ id: evt.id,
105
+ name: evt.name,
106
+ input: evt.input,
107
+ thoughtSignature: evt.thoughtSignature,
108
+ });
104
109
  init.onEvent({ type: 'tool_use', id: evt.id, name: evt.name, input: evt.input });
105
110
  break;
106
111
  case 'error':
@@ -136,14 +141,17 @@ export function createPiSession(init: PiSessionInit): PiSession {
136
141
  }
137
142
  }
138
143
 
139
- async function runOneTurn(userMsg: PiMessage): Promise<void> {
144
+ async function runOneTurn(input: AsyncQueue<PiMessage>, firstUserMsg: PiMessage): Promise<void> {
140
145
  if (init.abortController.signal.aborted) return;
141
- messages.push(userMsg);
146
+ // Stack any messages that arrived in the same millisecond into one turn.
147
+ messages.push(firstUserMsg);
148
+ for (const extra of input.drainPending()) messages.push(extra);
142
149
  init.onEvent({ type: 'turn_started' });
143
150
 
144
151
  let accumulatedText = '';
145
152
  const usedTools = new Set<string>();
146
153
  let turnErrored = false;
154
+ let pendingInterleave = false;
147
155
 
148
156
  for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
149
157
  if (init.abortController.signal.aborted) break;
@@ -157,14 +165,21 @@ export function createPiSession(init: PiSessionInit): PiSession {
157
165
  assistantContent.push({ type: 'text', text });
158
166
  }
159
167
  for (const tu of toolUses) {
160
- assistantContent.push({ type: 'tool_use', id: tu.id, name: tu.name, input: tu.input });
168
+ assistantContent.push({
169
+ type: 'tool_use',
170
+ id: tu.id,
171
+ name: tu.name,
172
+ input: tu.input,
173
+ // Forward Gemini's thoughtSignature unchanged so the next turn's
174
+ // request echoes it back; without it the API rejects with 400.
175
+ thoughtSignature: tu.thoughtSignature,
176
+ });
161
177
  }
162
178
  if (assistantContent.length > 0) {
163
179
  messages.push({ role: 'assistant', content: assistantContent });
164
180
  }
165
181
 
166
182
  if (errored) { turnErrored = true; break; }
167
- if (toolUses.length === 0) break; // model finished — exit loop
168
183
 
169
184
  // Run every tool the model asked for this round, then feed the results
170
185
  // back as a single user message Gemini accepts as a batch.
@@ -185,7 +200,24 @@ export function createPiSession(init: PiSessionInit): PiSession {
185
200
  if (toolResultBlocks.length > 0) {
186
201
  messages.push({ role: 'user', content: toolResultBlocks });
187
202
  }
188
- // Loop continues — re-stream with the new tool results in context.
203
+
204
+ // Fold any user messages that arrived during this round into history so
205
+ // the next stream pass sees them. This is what makes the conversation
206
+ // feel alive: while the agent is grinding on a long task, a question
207
+ // typed mid-stream lands in the very next request as a user-role part,
208
+ // and the model can answer it inline before continuing.
209
+ const interleaved = input.drainPending();
210
+ if (interleaved.length > 0) {
211
+ log.info(`[pi/session] interleaved ${interleaved.length} mid-turn user message(s) into history`);
212
+ for (const m of interleaved) messages.push(m);
213
+ pendingInterleave = true;
214
+ } else {
215
+ pendingInterleave = false;
216
+ }
217
+
218
+ // Exit when the model has nothing more to do AND no new user messages
219
+ // arrived mid-round. Either condition by itself keeps the loop alive.
220
+ if (toolUses.length === 0 && !pendingInterleave) break;
189
221
  }
190
222
 
191
223
  if (!turnErrored) {
@@ -202,7 +234,7 @@ export function createPiSession(init: PiSessionInit): PiSession {
202
234
  for await (const userMsg of input) {
203
235
  if (init.abortController.signal.aborted) break;
204
236
  try {
205
- await runOneTurn(userMsg);
237
+ await runOneTurn(input, userMsg);
206
238
  } catch (err: any) {
207
239
  log.warn(`[pi/session] Turn failed: ${err?.message || err}`);
208
240
  init.onEvent({ type: 'error', error: err?.message || String(err) });