@johpaz/hive-sdk 0.2.0 → 0.3.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +306 -0
  2. package/README.md +11 -3
  3. package/package.json +10 -4
  4. package/packages/core/src/agent/agent-catalog.ts +81 -24
  5. package/packages/core/src/agent/compaction.ts +20 -1
  6. package/packages/core/src/agent/context-compiler.ts +6 -3
  7. package/packages/core/src/agent/conversation-store.ts +136 -2
  8. package/packages/core/src/agent/curator.ts +12 -3
  9. package/packages/core/src/agent/llm-providers/nvidia.ts +39 -0
  10. package/packages/core/src/agent/llm-providers/openai-compat-base.ts +38 -2
  11. package/packages/core/src/agent/playbook-selector.ts +18 -3
  12. package/packages/core/src/agent/prompt-builder.ts +2 -2
  13. package/packages/core/src/agent/providers/index.ts +36 -1
  14. package/packages/core/src/agent/reflector.ts +32 -9
  15. package/packages/core/src/agent/skill-selector.ts +2 -2
  16. package/packages/core/src/agent/thread-store.ts +43 -0
  17. package/packages/core/src/agent/tool-selector.ts +2 -0
  18. package/packages/core/src/api/createAgent.ts +66 -1
  19. package/packages/core/src/artifacts/index.ts +15 -0
  20. package/packages/core/src/artifacts/store.ts +77 -2
  21. package/packages/core/src/canvas/index.ts +9 -0
  22. package/packages/core/src/ethics/EthicsGuard.ts +7 -1
  23. package/packages/core/src/events/index.ts +18 -0
  24. package/packages/core/src/events/tool-narration.ts +4 -0
  25. package/packages/core/src/gateway/channel-notify.ts +103 -6
  26. package/packages/core/src/gateway/durable-queue.ts +13 -1
  27. package/packages/core/src/gateway/index.ts +3 -0
  28. package/packages/core/src/gateway/job-store.ts +6 -0
  29. package/packages/core/src/harness/executors.ts +493 -0
  30. package/packages/core/src/harness/index.ts +12 -2
  31. package/packages/core/src/hooks/index.ts +203 -0
  32. package/packages/core/src/images/index.ts +161 -0
  33. package/packages/core/src/index.ts +1 -0
  34. package/packages/core/src/multimodal/vision-service.ts +45 -13
  35. package/packages/core/src/resilience/index.ts +13 -0
  36. package/packages/core/src/scheduler/CronScheduler.ts +48 -21
  37. package/packages/core/src/scheduler/cron/expression.ts +165 -0
  38. package/packages/core/src/scheduler/cron/index.ts +10 -0
  39. package/packages/core/src/scheduler/cron/job.ts +339 -0
  40. package/packages/core/src/scheduler/cron/next-run.ts +121 -0
  41. package/packages/core/src/scheduler/cron/zoned-time.ts +138 -0
  42. package/packages/core/src/scheduler/index.ts +21 -3
  43. package/packages/core/src/scheduler/integration.ts +16 -5
  44. package/packages/core/src/scheduler/types.ts +3 -18
  45. package/packages/core/src/services/agents.ts +268 -0
  46. package/packages/core/src/services/cron.ts +257 -0
  47. package/packages/core/src/services/endpoints.ts +289 -0
  48. package/packages/core/src/services/ethics.ts +107 -0
  49. package/packages/core/src/services/images.ts +212 -0
  50. package/packages/core/src/services/index.ts +112 -0
  51. package/packages/core/src/services/mcp.ts +201 -0
  52. package/packages/core/src/services/memory.ts +133 -0
  53. package/packages/core/src/services/models.ts +179 -0
  54. package/packages/core/src/services/providers.ts +152 -0
  55. package/packages/core/src/services/setup.ts +222 -0
  56. package/packages/core/src/services/skills.ts +241 -0
  57. package/packages/core/src/services/swarms.ts +307 -0
  58. package/packages/core/src/services/tools.ts +106 -0
  59. package/packages/core/src/sessions/index.ts +5 -3
  60. package/packages/core/src/sessions/resolve.ts +108 -0
  61. package/packages/core/src/skills/SkillLoader.ts +8 -1
  62. package/packages/core/src/skills/bundled/artifacts/artifact_reader/SKILL.md +105 -0
  63. package/packages/core/src/skills/bundled/cron_manager/SKILL.md +21 -11
  64. package/packages/core/src/skills/bundled/images/image_editor/SKILL.md +120 -0
  65. package/packages/core/src/skills/bundled/web/browser_automate/SKILL.md +12 -3
  66. package/packages/core/src/skills/bundled/web/browser_scrape/SKILL.md +22 -7
  67. package/packages/core/src/skills/bundled-data.generated.ts +110 -12
  68. package/packages/core/src/storage/bootstrap.ts +74 -5
  69. package/packages/core/src/storage/collections.ts +106 -1
  70. package/packages/core/src/storage/crypto.ts +24 -7
  71. package/packages/core/src/storage/index.ts +2 -1
  72. package/packages/core/src/storage/onboarding.ts +59 -43
  73. package/packages/core/src/storage/reconcile.ts +6 -1
  74. package/packages/core/src/storage/seed.ts +89 -11
  75. package/packages/core/src/swarm/types.ts +3 -18
  76. package/packages/core/src/tool-runtime/embedded-worker.generated.ts +21 -0
  77. package/packages/core/src/tool-runtime/index.ts +129 -14
  78. package/packages/core/src/tools/agents/index.ts +18 -60
  79. package/packages/core/src/tools/cli/index.ts +55 -0
  80. package/packages/core/src/tools/core/index.ts +50 -2
  81. package/packages/core/src/tools/cron/index.ts +4 -4
  82. package/packages/core/src/tools/images/index.ts +130 -0
  83. package/packages/core/src/tools/index.ts +14 -1
  84. package/packages/core/src/tools/office/office-escribir-xlsx.ts +2 -1
  85. package/packages/core/src/tools/office/office-leer-xlsx.ts +2 -1
  86. package/packages/core/src/tools/office/xlsx-loader.ts +19 -0
@@ -29,7 +29,7 @@ import type { MCPClientManager } from "../mcp/index.ts"
29
29
  import { syncToolCatalogToIndex, mcpToolFullName } from "./tool-selector.ts"
30
30
  import { syncSkillsToIndex, getMinimalSkills, selectSkills, getSkillByName, type SkillDescriptor } from "./skill-selector.ts"
31
31
  import { syncPlaybookToIndex, selectPlaybookRules } from "./playbook-selector.ts"
32
- import { getRecentMessages, getSummary, getScratchpad, toAPIMessages } from "./conversation-store.ts"
32
+ import { getRecentMessages, getSummary, getScratchpad, toAPIMessages, inflateRecentImages } from "./conversation-store.ts"
33
33
  import { formatContext, estimateTokens } from "../utils/toon.ts"
34
34
  import { buildSystemPromptWithProjects } from "./prompt-builder.ts"
35
35
  import { createAllTools } from "../tools/index.ts"
@@ -518,7 +518,10 @@ export async function compileContext(opts: {
518
518
  // or — on a context-overflow retry that keeps only the LAST system message
519
519
  // (openai-compat-base.ts) — silently replaces the real prompt. The summary
520
520
  // lives in `systemPrompt` instead (see conversationSummarySection below).
521
- const messages: LLMMessage[] = toAPIMessages(recentMessages)
521
+ // En el historial las imágenes son referencias, para no reenviarlas enteras en
522
+ // cada turno. Las de los últimos mensajes se vuelven a poner en línea: el
523
+ // modelo todavía puede necesitar mirarlas, y una referencia no se mira.
524
+ const messages: LLMMessage[] = await inflateRecentImages(toAPIMessages(recentMessages))
522
525
 
523
526
  // [STEP-10] STRATEGY 4: ISOLATE — Build context based on agent role
524
527
  log.info(`[context-compiler] [STEP-10] Building system prompt...`)
@@ -562,7 +565,7 @@ export async function compileContext(opts: {
562
565
  : Array.isArray(playbookInput)
563
566
  ? playbookInput.filter((part) => part.type === "text").map((part) => (part as any).text).join("\n")
564
567
  : String(playbookInput)
565
- const playbookRules = (await selectPlaybookRules(playbookText)).filter((rule) => {
568
+ const playbookRules = (await selectPlaybookRules(playbookText, userId)).filter((rule) => {
566
569
  if (!rule.applicable_to || !rule.applicable_to.includes("agent:")) return true
567
570
  return isCatalogAgent ? rule.applicable_to.includes(`agent:${agent.id}`) : false
568
571
  })
@@ -111,6 +111,66 @@ export function getRecentMessageCount(windowMs = 5 * 60_000): number {
111
111
  return recentMessageTimestamps.length
112
112
  }
113
113
 
114
+ /** El threadId es `${userId}/${canal}/${peer}`, así que el dueño ya está ahí. */
115
+ async function resolveOwnerId(threadId: string): Promise<string> {
116
+ const { parseThreadId } = await import("./thread-id.ts")
117
+ return parseThreadId(threadId)?.userId ?? threadId
118
+ }
119
+
120
+ /**
121
+ * Cuánto ocupa una imagen en la ventana de contexto.
122
+ *
123
+ * Los proveedores cobran por área, no por bytes: la fórmula es la de los
124
+ * modelos de visión más comunes (~750 px² por token). Es una estimación, pero
125
+ * cualquier estimación es infinitamente mejor que la anterior, que era cero: la
126
+ * compactación creía que un hilo con diez fotos ocupaba lo que ocupa su texto,
127
+ * y no se disparaba hasta que el proveedor rechazaba el turno.
128
+ */
129
+ export function estimateImageTokens(width?: number | null, height?: number | null): number {
130
+ if (!width || !height) return 1_500; // desconocida: el promedio de una foto
131
+ return Math.ceil((width * height) / 750);
132
+ }
133
+
134
+ /**
135
+ * Cambia las imágenes en línea por referencias a un artefacto.
136
+ *
137
+ * El base64 se guardaba entero en `content_multimodal` y `toAPIMessages` lo
138
+ * devolvía al modelo **en cada turno siguiente**: cinco fotos en una
139
+ * conversación eran cinco fotos reenviadas una y otra vez. Guardar el archivo y
140
+ * dejar una referencia corta ese crecimiento de raíz.
141
+ *
142
+ * La imagen no se pierde: `inflateRecentImages` la vuelve a poner en línea para
143
+ * los últimos turnos, que es donde el modelo todavía puede necesitar mirarla.
144
+ */
145
+ async function imagesToRefs(content: ContentPart[], userId: string): Promise<ContentPart[]> {
146
+ const { createArtifact } = await import("../artifacts/store.ts")
147
+ const out: ContentPart[] = []
148
+
149
+ for (const part of content) {
150
+ if (part.type !== "image_base64") { out.push(part); continue }
151
+ try {
152
+ const bytes = Uint8Array.from(Buffer.from((part as { base64: string }).base64, "base64"))
153
+ const mimeType = (part as { mimeType?: string }).mimeType || "image/jpeg"
154
+ // Si no se puede medir, no es una imagen. Guardarla igual crearía un
155
+ // artefacto de tipo "image" con basura adentro, que aparecería en la
156
+ // galería del usuario; es mejor dejarla como venía.
157
+ const { measureImage } = await import("../images/index.ts")
158
+ const meta = await measureImage(bytes)
159
+
160
+ const art = await createArtifact({
161
+ bytes, mimeType, kind: "image", userId,
162
+ width: meta.width, height: meta.height, expiresAt: null,
163
+ })
164
+ out.push({ type: "artifact_ref", artifact_id: art.id, mime_type: mimeType, width: meta.width, height: meta.height } as unknown as ContentPart)
165
+ } catch {
166
+ // No es una imagen medible, o no se pudo guardar: viaja como venía.
167
+ // Perderla sería peor que no optimizarla.
168
+ out.push(part)
169
+ }
170
+ }
171
+ return out
172
+ }
173
+
114
174
  export async function addMessage(
115
175
  threadId: string,
116
176
  role: StoredMessage["role"],
@@ -121,6 +181,8 @@ export async function addMessage(
121
181
  tool_call_id?: string
122
182
  reasoning_content?: string
123
183
  source?: MessageSource
184
+ /** Dueño de los artefactos que se creen para este mensaje (imágenes). */
185
+ userId?: string
124
186
  }
125
187
  ): Promise<number> {
126
188
  // Handle multimodal content by extracting text for the content column
@@ -130,7 +192,12 @@ export async function addMessage(
130
192
  ? content.filter(p => p.type === "text").map(p => (p as any).text).join("\n")
131
193
  : String(content)
132
194
 
133
- const content_multimodal = Array.isArray(content) ? JSON.stringify(content) : null
195
+ // Las imágenes se guardan como archivo y en el historial queda una
196
+ // referencia: el base64 entero se reenviaba al modelo en cada turno.
197
+ const partes = Array.isArray(content)
198
+ ? await imagesToRefs(content, opts?.userId ?? (await resolveOwnerId(threadId)))
199
+ : null
200
+ const content_multimodal = partes ? JSON.stringify(partes) : null
134
201
  const tool_calls_json = opts?.tool_calls ? JSON.stringify(opts.tool_calls) : null
135
202
 
136
203
  const paddedSeq = await nextId(`conversations:${threadId}`)
@@ -150,7 +217,19 @@ export async function addMessage(
150
217
  reasoning_content: opts?.reasoning_content ?? null,
151
218
  source: opts?.source ?? "message",
152
219
  // Estimate tokens: content + tool_calls JSON
153
- token_count: Math.max(1, estimateTokens(textContent) + estimateTokens(tool_calls_json ?? "")),
220
+ // Las imágenes cuentan: antes sumaban cero y la compactación creía que un
221
+ // hilo lleno de fotos ocupaba lo que ocupa su texto.
222
+ token_count: Math.max(
223
+ 1,
224
+ estimateTokens(textContent) +
225
+ estimateTokens(tool_calls_json ?? "") +
226
+ (partes ?? []).reduce((n, p) => {
227
+ const q = p as { type: string; width?: number | null; height?: number | null }
228
+ return q.type === "artifact_ref" || q.type === "image_base64" || q.type === "image_url"
229
+ ? n + estimateImageTokens(q.width, q.height)
230
+ : n
231
+ }, 0),
232
+ ),
154
233
  created_at: now,
155
234
  updated_at: now,
156
235
  }, { expectedVersion: 0 })
@@ -252,6 +331,61 @@ export async function getMessagesAfter(threadId: string, afterId: number): Promi
252
331
 
253
332
  // ─── Convert stored messages → LLMMessage array ───────────────────────────────
254
333
 
334
+ /**
335
+ * Cuántos mensajes del final conservan sus imágenes en línea.
336
+ *
337
+ * Mismo criterio que `clearOldToolResults` (compaction.ts), que ya poda
338
+ * resultados viejos y deja intactos los recientes. Es el compromiso: el modelo
339
+ * puede volver a mirar una imagen de hace un rato —"¿qué decía la factura?"—
340
+ * sin que una conversación larga arrastre todas las fotos para siempre.
341
+ */
342
+ export const KEEP_IMAGES_LAST_N = 6
343
+
344
+ /**
345
+ * Vuelve a poner en línea las imágenes de los últimos mensajes.
346
+ *
347
+ * En el historial las imágenes son referencias (ver `imagesToRefs`), que no
348
+ * ocupan contexto pero tampoco se pueden mirar: un modelo de visión no ve una
349
+ * foto desde un id. Para los últimos `keepLastN` mensajes se leen del disco y
350
+ * se devuelven como base64; los anteriores quedan como referencia, con sus
351
+ * dimensiones, para que el modelo sepa que hubo una imagen y cuál.
352
+ */
353
+ export async function inflateRecentImages(
354
+ messages: LLMMessage[],
355
+ keepLastN = KEEP_IMAGES_LAST_N,
356
+ ): Promise<LLMMessage[]> {
357
+ const desde = Math.max(0, messages.length - keepLastN)
358
+ const tieneRefs = messages.slice(desde).some((m) =>
359
+ Array.isArray(m.content) && m.content.some((p) => (p as { type?: string }).type === "artifact_ref"))
360
+ if (!tieneRefs) return messages
361
+
362
+ const { readArtifactBytes } = await import("../artifacts/store.ts")
363
+
364
+ return Promise.all(messages.map(async (msg, i) => {
365
+ if (i < desde || !Array.isArray(msg.content)) return msg
366
+
367
+ const partes = await Promise.all(msg.content.map(async (part) => {
368
+ const p = part as { type: string; artifact_id?: string; mime_type?: string }
369
+ if (p.type !== "artifact_ref" || !p.artifact_id) return part
370
+ if (!String(p.mime_type ?? "").startsWith("image/")) return part
371
+
372
+ try {
373
+ const datos = await readArtifactBytes(p.artifact_id)
374
+ if (!datos) return part // caducó o se borró: queda la referencia
375
+ return {
376
+ type: "image_base64",
377
+ base64: Buffer.from(datos.bytes).toString("base64"),
378
+ mimeType: datos.mimeType,
379
+ } as unknown as ContentPart
380
+ } catch {
381
+ return part
382
+ }
383
+ }))
384
+
385
+ return { ...msg, content: partes }
386
+ }))
387
+ }
388
+
255
389
  export function toAPIMessages(rows: StoredMessage[]): LLMMessage[] {
256
390
  return rows.map((r) => {
257
391
  let content: string | ContentPart[] = r.content
@@ -201,7 +201,13 @@ async function processReflection(
201
201
 
202
202
  // Check if a similar rule already exists (fuzzy check by first 60 chars)
203
203
  const prefix = reflection.description.substring(0, 60)
204
- const existing = allPlaybook.find(e => e.doc.active && e.doc.rule.startsWith(prefix))
204
+ // La deduplicación también va por usuario: si dos personas producen la misma
205
+ // observación, son dos reglas. Buscando sólo por texto, lo aprendido de la
206
+ // segunda reforzaría la regla de la primera y la haría pesar más en un
207
+ // playbook que no es suyo.
208
+ const existing = allPlaybook.find(
209
+ e => e.doc.active && e.doc.user_id === reflection.user_id && e.doc.rule.startsWith(prefix)
210
+ )
205
211
 
206
212
  if (existing) {
207
213
  // Reinforce existing rule
@@ -216,6 +222,7 @@ async function processReflection(
216
222
  id,
217
223
  rule: reflection.description,
218
224
  category,
225
+ user_id: reflection.user_id,
219
226
  applicable_to: applicableTo,
220
227
  helpful_count: 1,
221
228
  harmful_count: 0,
@@ -224,7 +231,7 @@ async function processReflection(
224
231
  created_at: now,
225
232
  updated_at: now,
226
233
  }, { expectedVersion: 0 })
227
- allPlaybook.push({ id, version: 1, doc: { id, rule: reflection.description, category, applicable_to: applicableTo, helpful_count: 1, harmful_count: 0, active: true, source_reflection_id: toIndexable(reflection.id), created_at: now, updated_at: now } })
234
+ allPlaybook.push({ id, version: 1, doc: { id, rule: reflection.description, category, user_id: reflection.user_id, applicable_to: applicableTo, helpful_count: 1, harmful_count: 0, active: true, source_reflection_id: toIndexable(reflection.id), created_at: now, updated_at: now } })
228
235
  }
229
236
 
230
237
  function mapInsightTypeToCategory(
@@ -248,12 +255,13 @@ async function addOrUpdateRule(
248
255
  opts: {
249
256
  rule: string
250
257
  category: string
258
+ user_id: string
251
259
  applicable_to: string | null
252
260
  sourceReflectionId: string | null
253
261
  }
254
262
  ): Promise<void> {
255
263
  const prefix = opts.rule.substring(0, 60)
256
- const existing = allPlaybook.find(e => e.doc.rule.startsWith(prefix))
264
+ const existing = allPlaybook.find(e => e.doc.user_id === opts.user_id && e.doc.rule.startsWith(prefix))
257
265
 
258
266
  if (existing) {
259
267
  await playbookCol.put(existing.id, { ...existing.doc, helpful_count: existing.doc.helpful_count + 1, updated_at: Date.now() }, { expectedVersion: existing.version })
@@ -264,6 +272,7 @@ async function addOrUpdateRule(
264
272
  id,
265
273
  rule: opts.rule,
266
274
  category: opts.category as PlaybookDoc["category"],
275
+ user_id: opts.user_id,
267
276
  applicable_to: opts.applicable_to,
268
277
  helpful_count: 1,
269
278
  harmful_count: 0,
@@ -1,5 +1,44 @@
1
1
  import { OpenAICompatBase } from "./openai-compat-base.ts"
2
+ import type { LLMCallOptions } from "../llm-client.ts"
3
+
4
+ /**
5
+ * NIM mantiene el razonamiento APAGADO por defecto en su endpoint compatible
6
+ * con OpenAI, y el interruptor no es el `reasoning_effort` de OpenAI sino
7
+ * `chat_template_kwargs`, con una clave distinta por familia de modelo.
8
+ *
9
+ * Verificado en vivo contra integrate.api.nvidia.com (2026-08-15):
10
+ * `z-ai/glm-5.2` no emite un solo `reasoning_content` sin esto — el delta trae
11
+ * únicamente `role` y `content` — y sí lo emite con `enable_thinking`. Por eso
12
+ * la app no mostraba razonamiento con los modelos de NVIDIA: no llegaba.
13
+ *
14
+ * Las familias que no están acá se dejan en paz a propósito. Nemotron 3 ya
15
+ * emite `reasoning_content` sin ningún kwarg (también verificado), y mandarle
16
+ * una clave que su plantilla no acepta es justamente lo que rompe la llamada.
17
+ */
18
+ const THINKING_KWARGS: Array<{ pattern: RegExp; kwargs: Record<string, unknown> }> = [
19
+ // Verificado con z-ai/glm-5.2.
20
+ { pattern: /glm/i, kwargs: { enable_thinking: true, clear_thinking: false } },
21
+ // Verificado con minimaxai/minimax-m3.
22
+ { pattern: /minimax/i, kwargs: { thinking_mode: "enabled" } },
23
+ // Documentado por NVIDIA, sin verificar: moonshotai/kimi-k2.6 responde 404
24
+ // en la cuenta con la que se probó. Si la plantilla lo rechaza, el reintento
25
+ // de openai-compat-base repite la llamada sin kwargs.
26
+ { pattern: /kimi|deepseek/i, kwargs: { thinking: true } },
27
+ { pattern: /qwen|qwq/i, kwargs: { enable_thinking: true } },
28
+ ]
2
29
 
3
30
  export class NvidiaProvider extends OpenAICompatBase {
4
31
  constructor() { super("nvidia") }
32
+
33
+ protected modifyRequestBody(body: any, options: LLMCallOptions): any {
34
+ if (!options.thinking?.enabled) return body
35
+
36
+ const match = THINKING_KWARGS.find(({ pattern }) => pattern.test(options.model))
37
+ if (!match) return body
38
+
39
+ return {
40
+ ...body,
41
+ chat_template_kwargs: { ...(body.chat_template_kwargs ?? {}), ...match.kwargs },
42
+ }
43
+ }
5
44
  }
@@ -7,6 +7,32 @@ import {
7
7
  import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface.ts"
8
8
  import type { ContentPart, LLMMessage } from "../llm-client.ts"
9
9
 
10
+ /**
11
+ * Statuses that mean "this body is malformed or unsupported" — the only ones
12
+ * where dropping the provider extras can possibly help.
13
+ *
14
+ * Deliberately narrow. A first version retried on any status and immediately
15
+ * misfired in production against a 429: a rate limit is not a body problem, so
16
+ * the retry spent a second request against the very limit that rejected the
17
+ * first, and turned thinking off for nothing. Same for 401/403 (auth) and 404
18
+ * (unknown model).
19
+ */
20
+ const EXTRAS_REJECTED_CODES = [400, 422]
21
+
22
+ /**
23
+ * Drops the non-standard fields a provider added on top of an OpenAI-shaped
24
+ * body, for use on a retry after the body itself was rejected. Right now that
25
+ * is only NIM's `chat_template_kwargs` (nvidia.ts): it enables the model's
26
+ * thinking, so losing it costs the reasoning display and nothing else — a far
27
+ * better outcome than a turn that dies because one model's chat template did
28
+ * not recognize the switch.
29
+ */
30
+ function stripProviderExtras(body: any): any {
31
+ if (!body?.chat_template_kwargs) return body
32
+ const { chat_template_kwargs: _dropped, ...rest } = body
33
+ return rest
34
+ }
35
+
10
36
  const log = logger.child("llm-client")
11
37
 
12
38
  /** Matches both generic "context length exceeded" phrasing and llama.cpp's exceed_context_size_error shape. */
@@ -187,7 +213,14 @@ export abstract class OpenAICompatBase implements LLMProvider {
187
213
  delete bodyNoTools.tools
188
214
  delete bodyNoTools.tool_choice
189
215
  delete bodyNoTools.parallel_tool_calls
190
- response = await client.chat.completions.create(this.modifyRequestBody(bodyNoTools, options), { signal: options.signal })
216
+ response = await client.chat.completions.create(stripProviderExtras(this.modifyRequestBody(bodyNoTools, options)), { signal: options.signal })
217
+ }
218
+ // Retry 3: the provider-specific extras are the only other thing we added
219
+ // to an otherwise standard body (NIM's chat_template_kwargs — see
220
+ // nvidia.ts). Losing the reasoning display beats failing the turn.
221
+ else if (EXTRAS_REJECTED_CODES.includes(status) && this.modifyRequestBody(body, options).chat_template_kwargs) {
222
+ log.warn(`[llm-client] ${this.providerName}: request rejected (HTTP ${status}) — retrying without chat_template_kwargs`)
223
+ response = await client.chat.completions.create(stripProviderExtras(this.modifyRequestBody(body, options)), { signal: options.signal })
191
224
  }
192
225
  else {
193
226
  throw err
@@ -258,7 +291,10 @@ export abstract class OpenAICompatBase implements LLMProvider {
258
291
  delete bodyNoTools.tools
259
292
  delete bodyNoTools.tool_choice
260
293
  delete bodyNoTools.parallel_tool_calls
261
- stream = await client.chat.completions.create({ ...this.modifyRequestBody(bodyNoTools, options), stream: true }, { signal: options.signal })
294
+ stream = await client.chat.completions.create({ ...stripProviderExtras(this.modifyRequestBody(bodyNoTools, options)), stream: true }, { signal: options.signal })
295
+ } else if (EXTRAS_REJECTED_CODES.includes(status) && this.modifyRequestBody(body, options).chat_template_kwargs) {
296
+ log.warn(`[llm-client] ${this.providerName}: request rejected (HTTP ${status}) — retrying stream without chat_template_kwargs`)
297
+ stream = await client.chat.completions.create({ ...stripProviderExtras(this.modifyRequestBody(body, options)), stream: true }, { signal: options.signal })
262
298
  } else {
263
299
  throw err
264
300
  }
@@ -39,20 +39,33 @@ const MAX_RULES_PER_TURN = 5
39
39
  */
40
40
  const RELEVANCE_RATIO = 0.3
41
41
 
42
+ /** Cuántos candidatos se piden de más para sobrevivir al filtro por usuario. */
43
+ const OVERFETCH_FACTOR = 4
44
+
42
45
  // ─── Selection Logic ───────────────────────────────────────────────────────────
43
46
 
44
47
  /**
45
- * Select relevant rules from the Playbook based on semantic matching
48
+ * Select relevant rules from the Playbook based on semantic matching.
49
+ *
50
+ * `userId` acota lo aprendido a quien corresponde: una regla se aplica si es
51
+ * global (`user_id === ""`, el conocimiento sembrado con el producto) o si
52
+ * salió de las trazas de este mismo usuario. Sin este filtro, lo que el agente
53
+ * aprende hablando con una persona termina inyectado en el prompt de otra.
54
+ * Omitirlo devuelve sólo las reglas globales.
46
55
  */
47
- export async function selectPlaybookRules(message: string): Promise<PlaybookRule[]> {
56
+ export async function selectPlaybookRules(message: string, userId?: string): Promise<PlaybookRule[]> {
48
57
  const startTime = performance.now()
49
58
 
50
59
  if (!message.trim()) return []
51
60
 
52
61
  try {
62
+ // El índice BM25 es uno solo para todos los usuarios, así que pedir k
63
+ // resultados y filtrar después dejaría a un usuario sin reglas cuando
64
+ // las mejor puntuadas son de otro. Se pide un pozo más ancho y se
65
+ // recorta a MAX_RULES_PER_TURN recién después de filtrar.
53
66
  const hits = await searchCapabilities(message, {
54
67
  types: ["playbook"],
55
- k: MAX_RULES_PER_TURN,
68
+ k: MAX_RULES_PER_TURN * OVERFETCH_FACTOR,
56
69
  })
57
70
 
58
71
  const relevantIds = applyRelativeCutoff(hits, RELEVANCE_RATIO).map(h => h.rawId)
@@ -64,6 +77,8 @@ export async function selectPlaybookRules(message: string): Promise<PlaybookRule
64
77
  const entries = await Promise.all(relevantIds.map(id => playbookCol.get(id)))
65
78
  const rules: PlaybookRule[] = entries
66
79
  .filter((e): e is NonNullable<typeof e> => !!e && e.doc.active)
80
+ .filter(e => e.doc.user_id === "" || e.doc.user_id === (userId ?? ""))
81
+ .slice(0, MAX_RULES_PER_TURN)
67
82
  .map(e => ({
68
83
  id: e.id,
69
84
  rule: e.doc.rule,
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { col } from "../storage/hive.ts"
17
- import type { EthicsDoc, AgentDoc, UserDoc } from "../storage/collections.ts"
17
+ import type { EthicsDoc, AgentDoc, UserDoc } from "../storage/collections"
18
18
  import { logger } from "../utils/logger.ts"
19
19
  import { formatContext } from "../utils/toon.ts"
20
20
  import { resolveUserId } from "../storage/onboarding.ts"
@@ -136,7 +136,7 @@ export async function buildSystemPrompt(opts: BuildSystemPromptOpts): Promise<st
136
136
  if (Object.keys(userData).length > 0) {
137
137
  userSection += formatContext(userData) + "\n\n"
138
138
  if (agent.role === "coordinator" && user.email) {
139
- userSection += `Cuando el usuario diga "envíame", "mándame" o "a mi correo" sin indicar otro destinatario, usá CorreoPropio. Para terceras personas, resolvé su dirección por separado.\n\n`
139
+ userSection += `Cuando el usuario diga "envíame", "mándame" o "a mi correo" sin indicar otro destinatario, usa CorreoPropio. Para terceras personas, resuelve su dirección por separado.\n\n`
140
140
  }
141
141
  } else {
142
142
  userSection += `Usuario ID: ${userId}\n\n`
@@ -7,10 +7,11 @@
7
7
 
8
8
  import type { Config } from "../../config/loader.ts"
9
9
  import { logger } from "../../utils/logger.ts"
10
- import { getAgentLoop } from "../agent-loop.ts"
10
+ import { getAgentLoop, buildAgentLoop } from "../agent-loop.ts"
11
11
  import { resolveUserId, resolveAgentId } from "../../storage/onboarding.ts"
12
12
  import type { ContentPart } from "../../multimodal/types.ts"
13
13
  import type { TurnSource } from "../../storage/collections.ts"
14
+ import type { MCPClientManager } from "../../mcp/index.ts"
14
15
 
15
16
  export type Provider = "openai" | "anthropic" | "gemini" | "mistral" | "kimi" | "ollama" | "openrouter" | "deepseek" | "nvidia" | "hiveagents" | "z-ai" | "modelscope" | "minimax" | "qwen" | "groq" | "opencode-go"
16
17
 
@@ -62,6 +63,34 @@ export interface ModelResponse {
62
63
  totalTokens: number
63
64
  }
64
65
  finishReason?: string
66
+ /** Image artifacts (mcp-result-normalizer.ts, agent-loop.ts's turnImageArtifacts) produced by tools this turn. */
67
+ imageArtifacts?: Array<{ artifactId: string; mimeType: string }>
68
+ }
69
+
70
+ /**
71
+ * Crea un `AgentRunner` listo para usar.
72
+ *
73
+ * `AgentRunner.generate()` necesita que el loop global exista
74
+ * (`getAgentLoop()`), y construirlo es un paso aparte que hay que recordar:
75
+ * `new AgentRunner(config)` a secas compila, se instancia sin quejarse y falla
76
+ * recién en la primera llamada con "AgentLoop not initialized". En hive ese paso
77
+ * lo hace su initializer; en el SDK no lo hacía nadie, así que la clase estaba
78
+ * exportada pero no era utilizable.
79
+ *
80
+ * Esta fábrica hace las dos cosas en el orden correcto. Es idempotente: llamarla
81
+ * dos veces reconstruye el loop con el manager que se le pase.
82
+ *
83
+ * ```ts
84
+ * const runner = await createAgentRunner(loadConfig(), { mcpManager });
85
+ * const res = await runner.generate({ messages: [...] });
86
+ * ```
87
+ */
88
+ export async function createAgentRunner(
89
+ config: Config,
90
+ opts: { mcpManager?: MCPClientManager | null } = {},
91
+ ): Promise<AgentRunner> {
92
+ buildAgentLoop({ mcpManager: opts.mcpManager ?? null })
93
+ return new AgentRunner(config)
65
94
  }
66
95
 
67
96
  export class AgentRunner {
@@ -87,6 +116,7 @@ export class AgentRunner {
87
116
  throw new Error("AgentLoop not initialized")
88
117
  }
89
118
 
119
+ let imageArtifacts: ModelResponse["imageArtifacts"]
90
120
  let lastAgentContent = ""
91
121
  let accumulatedAgentContent = "" // Accumulate content from all agent chunks
92
122
  let toolCalls: ModelResponse["toolCalls"] = []
@@ -190,6 +220,10 @@ export class AgentRunner {
190
220
  totalInputTokens += chunk.usage.input_tokens
191
221
  totalOutputTokens += chunk.usage.output_tokens
192
222
  }
223
+
224
+ if (chunk.artifacts?.images?.length) {
225
+ imageArtifacts = [...(imageArtifacts ?? []), ...chunk.artifacts.images]
226
+ }
193
227
  }
194
228
 
195
229
  logger.debug(`[STREAM] done. totalChunks=${chunkCount} lastAgentContent length=${lastAgentContent.length}, accumulated length=${accumulatedAgentContent.length}`)
@@ -208,6 +242,7 @@ export class AgentRunner {
208
242
  totalTokens: totalInputTokens + totalOutputTokens,
209
243
  },
210
244
  finishReason: "stop",
245
+ imageArtifacts,
211
246
  }
212
247
  } catch (error) {
213
248
  logger.error("AgentRunner error:", error)
@@ -17,6 +17,7 @@ import { getHiveDb } from "../storage/hivedb.ts"
17
17
  import { loadConfig } from "../config/loader.ts"
18
18
  import type { HiveDB, ToolStats } from "@johpaz/hive-db"
19
19
  import type { TraceDoc, ReflectionDoc, CursorDoc } from "../storage/collections.ts"
20
+ import { parseThreadId } from "./thread-id.ts"
20
21
 
21
22
  const log = logger.child("reflector")
22
23
 
@@ -51,25 +52,42 @@ export async function runReflector(): Promise<void> {
51
52
 
52
53
  log.info(`[reflector] Analyzing ${traces.length} traces...`)
53
54
 
55
+ // Las trazas se agrupan por usuario antes de analizarlas.
56
+ //
57
+ // Antes se analizaba el lote entero de una vez, así que una reflexión —y la
58
+ // regla de playbook que sale de ella— mezclaba lo aprendido de varias
59
+ // personas. En una instalación de un solo usuario da igual; en un host
60
+ // multi-inquilino significa que lo aprendido de un workspace se le aplica a
61
+ // otro. El usuario sale del `thread_id`, que ya lo lleva dentro.
62
+ const porUsuario = new Map<string, typeof traceEntries>()
63
+ for (const entry of traceEntries) {
64
+ const usuario = parseThreadId(entry.doc.thread_id)?.userId ?? ""
65
+ const grupo = porUsuario.get(usuario) ?? []
66
+ grupo.push(entry)
67
+ porUsuario.set(usuario, grupo)
68
+ }
69
+
54
70
  // G9: when enabled, per-tool insights use whole-history stats from HiveDB's
55
71
  // event log (toolStats) instead of counters built from just this batch, and
56
72
  // causal threads add root-cause/learning-proposal insights on top.
57
73
  const causalDb = loadConfig().causalLog?.enabled ? await getHiveDb() : null
58
- const localInsights = await analyzeTracesLocally(traces, causalDb)
59
- const causalInsights = await analyzeCausalThreads(traces, causalDb)
60
- const insights = [...localInsights, ...causalInsights]
74
+ const reflectionsCol = await col<ReflectionDoc>("reflections")
75
+ let totalInsights = 0
61
76
 
62
- if (insights.length === 0) {
63
- log.debug("[reflector] No insights generated")
64
- } else {
65
- const traceIds = JSON.stringify(traceEntries.map(e => e.id))
66
- const reflectionsCol = await col<ReflectionDoc>("reflections")
77
+ for (const [usuario, entradas] of porUsuario) {
78
+ const delGrupo = entradas.map(e => e.doc)
79
+ const localInsights = await analyzeTracesLocally(delGrupo, causalDb)
80
+ const causalInsights = await analyzeCausalThreads(delGrupo, causalDb)
81
+ const insights = [...localInsights, ...causalInsights]
82
+ if (insights.length === 0) continue
67
83
 
84
+ const traceIds = JSON.stringify(entradas.map(e => e.id))
68
85
  for (const insight of insights) {
69
86
  const id = await nextId("reflections")
70
87
  await reflectionsCol.put(id, {
71
88
  id,
72
89
  trace_ids: traceIds,
90
+ user_id: usuario,
73
91
  insight_type: insight.type,
74
92
  description: insight.description,
75
93
  affected_tools: insight.affectedTools ? JSON.stringify(insight.affectedTools) : null,
@@ -78,8 +96,13 @@ export async function runReflector(): Promise<void> {
78
96
  created_at: Date.now(),
79
97
  })
80
98
  }
99
+ totalInsights += insights.length
100
+ }
81
101
 
82
- log.info(`[reflector] Generated ${insights.length} insights`)
102
+ if (totalInsights === 0) {
103
+ log.debug("[reflector] No insights generated")
104
+ } else {
105
+ log.info(`[reflector] Generated ${totalInsights} insights across ${porUsuario.size} user(s)`)
83
106
  }
84
107
 
85
108
  // Advance the cursor regardless of whether insights were generated —
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { col } from "../storage/hive.ts"
20
- import type { SkillDoc } from "../storage/collections.ts"
20
+ import type { SkillDoc } from "../storage/collections"
21
21
  import { logger } from "../utils/logger.ts"
22
22
  import { isMinimalSkill } from "./minimal-loadout.ts"
23
23
  import { isCalendarOperation } from "./routing-intent.ts"
@@ -109,7 +109,7 @@ const STOPWORDS = new Set([
109
109
  "más", "mas", "ya", "yo", "tu", "te", "ti", "mi", "me",
110
110
  "hola", "hi", "hello", "hey", "gracias", "thank", "please",
111
111
  "ok", "okay", "yes", "si", "no", "bien", "good", "great",
112
- "puedes", "necesito", "quiero", "podés", "necesitás", "querés",
112
+ "puedes", "necesito", "quiero", "necesitas", "quieres",
113
113
  ])
114
114
 
115
115
  /** Conversational patterns that should return empty skill list */