@johpaz/hive-sdk 0.4.9 → 0.5.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/README.md +9 -4
  3. package/docs/API-AGENTS.md +70 -0
  4. package/docs/INDEX.md +5 -1
  5. package/docs/UPGRADING.md +20 -0
  6. package/package.json +1 -1
  7. package/packages/core/src/agent/agent-loop.ts +82 -5
  8. package/packages/core/src/agent/context-compiler.ts +122 -23
  9. package/packages/core/src/agent/index.ts +2 -0
  10. package/packages/core/src/agent/jev-decisions.ts +197 -0
  11. package/packages/core/src/agent/jev-planner.ts +298 -0
  12. package/packages/core/src/agent/llm-client.ts +2 -2
  13. package/packages/core/src/agent/llm-providers/anthropic.ts +2 -1
  14. package/packages/core/src/agent/llm-providers/gemini.ts +3 -1
  15. package/packages/core/src/agent/realtime-providers/gemini-live.ts +2 -1
  16. package/packages/core/src/agent/tool-selector.ts +1 -0
  17. package/packages/core/src/canvas/emitter.ts +19 -0
  18. package/packages/core/src/events/channel-narration.ts +3 -0
  19. package/packages/core/src/multimodal/vision-service.ts +7 -7
  20. package/packages/core/src/services/swarms.ts +4 -0
  21. package/packages/core/src/storage/collections.ts +9 -2
  22. package/packages/core/src/storage/crypto.ts +51 -17
  23. package/packages/core/src/storage/index.ts +1 -0
  24. package/packages/core/src/storage/seed.ts +4 -0
  25. package/packages/core/src/storage/usage.ts +55 -2
  26. package/packages/core/src/swarm/RoleSwarm.ts +6 -0
  27. package/packages/core/src/tool-runtime/index.ts +20 -1
  28. package/packages/core/src/tools/agents/get-available-models.ts +3 -1
  29. package/packages/core/src/tools/core/index.ts +33 -2
  30. package/packages/core/src/tools/web/computer-use.ts +2 -2
  31. package/packages/core/src/voice/index.ts +13 -13
@@ -0,0 +1,298 @@
1
+ import type { LLMMessage, LLMToolDef } from "./llm-client.ts"
2
+ import type { SkillDescriptor } from "./skill-selector.ts"
3
+ import type { ContextTool } from "./context-compiler.ts"
4
+ import type { PlaybookRule } from "./playbook-selector.ts"
5
+ import { MINIMAL_TOOLS } from "./minimal-loadout.ts"
6
+ import { searchCapabilities } from "./capability-search.ts"
7
+ import { mcpToolFullName } from "./tool-selector.ts"
8
+ import { askJev, getJevKey, type JevAnswer, type JevOption, type JevQuestion } from "./jev-decisions.ts"
9
+ import { col } from "../storage/hive.ts"
10
+ import type { AgentDoc, McpServerDoc, McpToolDoc } from "../storage/collections.ts"
11
+
12
+ export interface JevDecisionMetrics {
13
+ latencyMs: number
14
+ costUsd: number
15
+ }
16
+
17
+ /** "activo": connected now; "disponible": enabled, connects on first use; "apagado": disabled. */
18
+ export type JevMcpState = "activo" | "disponible" | "apagado"
19
+
20
+ export interface JevMcpServer {
21
+ id: string
22
+ name: string
23
+ state: JevMcpState
24
+ tools: number
25
+ }
26
+
27
+ export interface JevSpecialist {
28
+ id: string
29
+ name: string
30
+ description: string
31
+ tools: string[]
32
+ mcp: Array<{ name: string; state: JevMcpState }>
33
+ }
34
+
35
+ /**
36
+ * What the swarm can do right now: every enabled worker (catalog and
37
+ * agent_create alike) with its tools, and every MCP server with its state.
38
+ * Jev routes and selects over this, so it never recommends a specialist whose
39
+ * MCP is off or proposes a tool that is not connected.
40
+ */
41
+ export async function describeSwarmCapabilities(
42
+ mcpManager: { getServerTools(key: string): unknown[] | undefined } | null,
43
+ opts: { includeSpecialists: boolean },
44
+ ): Promise<{ mcpServers: JevMcpServer[]; specialists: JevSpecialist[] }> {
45
+ const servers = (await (await col<McpServerDoc>("mcpServers")).scan({})).map(e => e.doc)
46
+ const mcpServers: JevMcpServer[] = servers.map(server => {
47
+ const tools = mcpManager?.getServerTools(server.id)?.length || mcpManager?.getServerTools(server.name)?.length || 0
48
+ return { id: server.id, name: server.name, tools, state: !server.enabled ? "apagado" : tools > 0 ? "activo" : "disponible" }
49
+ })
50
+ if (!opts.includeSpecialists) return { mcpServers, specialists: [] }
51
+
52
+ const byId = new Map(mcpServers.map(s => [s.id, s]))
53
+ const parse = (json: string | null | undefined): string[] => {
54
+ try { return json ? (JSON.parse(json) as unknown[]).map(String) : [] } catch { return [] }
55
+ }
56
+ const specialists = (await (await col<AgentDoc>("agents")).scan({}))
57
+ .map(e => e.doc)
58
+ .filter(a => a.role === "worker" && a.enabled && a.status !== "archived")
59
+ .map(a => ({
60
+ id: a.id,
61
+ name: a.name,
62
+ description: (a.description ?? a.name).slice(0, 200),
63
+ tools: parse(a.tool_allowlist_json).slice(0, 12),
64
+ mcp: parse(a.mcp_server_ids_json).map(id => byId.get(id)).filter((s): s is JevMcpServer => !!s)
65
+ .map(s => ({ name: s.name, state: s.state })),
66
+ }))
67
+ return { mcpServers, specialists }
68
+ }
69
+
70
+ /** One roster line: what the coordinator needs to pick a specialist without calling agent_find. */
71
+ export function renderSpecialistLine(s: JevSpecialist): string {
72
+ const mcp = s.mcp.length ? ` · MCP: ${s.mcp.map(m => `${m.name} (${m.state})`).join(", ")}` : ""
73
+ return `- ${s.id} (${s.name})${mcp}`
74
+ }
75
+
76
+ export interface JevContextPlan {
77
+ messages: LLMMessage[]
78
+ tools: LLMToolDef[]
79
+ skills: SkillDescriptor[]
80
+ agentId: string | null
81
+ /**
82
+ * MCP servers the recommended specialist depends on that are off. Non-empty
83
+ * means "this is the right specialist, but the user must turn these on
84
+ * first" — delegating now would hand it a task it cannot do.
85
+ */
86
+ agentMcpOff: string[]
87
+ selectedMessageIds: number[]
88
+ selectedToolNames: string[]
89
+ selectedSkillNames: string[]
90
+ selectedScratchpadKeys: string[]
91
+ selectedPlaybookIds: string[]
92
+ decision: JevDecisionMetrics
93
+ }
94
+
95
+ const excerpt = (value: unknown, max = 450): string =>
96
+ (typeof value === "string" ? value : JSON.stringify(value) ?? "").slice(0, max)
97
+ const probability = (answer: JevAnswer | undefined): number | null => answer?.type === "noul" ? answer.noul : null
98
+ const MANDATORY_TAIL = 4
99
+ /** Below this many characters of prunable tool output, a decision costs more latency than it saves. */
100
+ const MIN_PRUNABLE_CHARS = 4000
101
+
102
+ /** Decisions select only from already authorized, discoverable candidates. */
103
+ export async function planJevContext(input: {
104
+ objective: string
105
+ messages: LLMMessage[]
106
+ tools: LLMToolDef[]
107
+ allTools: ContextTool[]
108
+ skills: SkillDescriptor[]
109
+ scratchpadNotes?: Array<{ key: string; value: string }>
110
+ playbookRules?: PlaybookRule[]
111
+ isWorker: boolean
112
+ /** From describeSwarmCapabilities; absent means "unknown", not "none". */
113
+ swarm?: { mcpServers: JevMcpServer[]; specialists: JevSpecialist[] }
114
+ jev?: JevOption
115
+ }): Promise<JevContextPlan | null> {
116
+ if (!await getJevKey(input.jev).catch(() => null)) return null
117
+ const { objective, messages, tools, allTools, skills, isWorker } = input
118
+ const mandatoryMessages = new Set<number>()
119
+ // The last two exchanges carry the thread's immediate referents ("hazlo otra
120
+ // vez", "el anterior"); dropping them sent the model into conversation_read loops.
121
+ for (let i = Math.max(0, messages.length - MANDATORY_TAIL); i < messages.length; i++) mandatoryMessages.add(i)
122
+ messages.forEach((message, i) => {
123
+ if (Array.isArray(message.content) || (typeof message.content === "string" && message.content.startsWith("<hive:internal_event"))) mandatoryMessages.add(i)
124
+ })
125
+
126
+ const candidateMessageIds = messages.map((_, i) => i).filter(i => !mandatoryMessages.has(i))
127
+ let candidateTools: ContextTool[] = tools.filter(t => !MINIMAL_TOOLS.has(t.function.name))
128
+ .map(t => allTools.find(a => a.name === t.function.name))
129
+ .filter((t): t is ContextTool => !!t)
130
+ try {
131
+ const hits = await searchCapabilities(objective, { types: ["tool", "mcp"], k: 12 })
132
+ // allTools only holds MCP tools of servers that are enabled, connected and
133
+ // allowed for this agent — resolving through it is what keeps a dormant or
134
+ // foreign server's tools out of the plan.
135
+ const available = new Map(allTools.map(t => [t.name, t]))
136
+ const mcpTools = await col<McpToolDoc>("mcpTools")
137
+ const names = await Promise.all(hits.map(async h => {
138
+ if (h.type !== "mcp") return h.rawId
139
+ const tool = (await mcpTools.get(h.rawId))?.doc
140
+ return tool ? mcpToolFullName(tool.server_name, tool.tool_name) : null
141
+ }))
142
+ const discovered = names.map(n => n ? available.get(n) : undefined).filter((t): t is ContextTool => !!t && !MINIMAL_TOOLS.has(t.name))
143
+ candidateTools = [...new Map([...candidateTools, ...discovered].map(t => [t.name, t])).values()].slice(0, 24)
144
+ } catch { /* index unavailable: retain the current loadout */ }
145
+
146
+ const optionalSkills = skills.filter(s => s.active && s.body).slice(0, 8)
147
+ const scratchpadNotes = (input.scratchpadNotes ?? []).slice(-16)
148
+ const playbookRules = (input.playbookRules ?? []).slice(0, 8)
149
+ // Specialists with an MCP off stay eligible: Jev still names the right one,
150
+ // and the coordinator asks the user to turn the server on instead of
151
+ // delegating a task the specialist cannot do yet.
152
+ const agents = isWorker ? [] : (input.swarm?.specialists ?? []).slice(0, 16)
153
+ const questions: Record<string, JevQuestion> = {}
154
+ for (const i of candidateMessageIds) questions[`history_${i}`] = { type: "noul", instructions: `Is earlier conversation item ${i} necessary to complete the current objective?` }
155
+ for (const tool of candidateTools) questions[`tool_${tool.name}`] = { type: "noul", instructions: `Will tool ${tool.name} likely be needed for the current objective?` }
156
+ for (const skill of optionalSkills) questions[`skill_${skill.id}`] = { type: "noul", instructions: `Are instructions from skill ${skill.name} needed for the current objective?` }
157
+ for (const note of scratchpadNotes) questions[`note_${note.key}`] = { type: "noul", instructions: `Is scratchpad note ${note.key} needed for the current objective?` }
158
+ for (const rule of playbookRules) questions[`rule_${rule.id}`] = { type: "noul", instructions: `Does playbook rule ${rule.id} apply to the current objective?` }
159
+ if (agents.length) questions.agent = {
160
+ type: "choice", instructions: "Which existing specialist should handle a bounded part of this objective, or should the coordinator handle it?",
161
+ criteria: {
162
+ coordinator: "No bounded specialist task is needed",
163
+ ...Object.fromEntries(agents.map(a => [a.id, [
164
+ a.description,
165
+ a.tools.length ? `tools: ${a.tools.join(", ")}` : "",
166
+ a.mcp.length ? `MCP: ${a.mcp.map(m => `${m.name} (${m.state})`).join(", ")}` : "",
167
+ ].filter(Boolean).join(" · ").slice(0, 400)])),
168
+ },
169
+ }
170
+ const decision = await askJev({
171
+ objective: objective.slice(0, 3500),
172
+ history: candidateMessageIds.map(i => ({ id: i, role: messages[i].role, content: excerpt(messages[i].content) })),
173
+ tools: candidateTools.map(t => ({ name: t.name, description: t.description.slice(0, 240) })),
174
+ skills: optionalSkills.map(s => ({ id: s.id, name: s.name, description: s.description.slice(0, 240) })),
175
+ notes: scratchpadNotes.map(n => ({ key: n.key, value: n.value.slice(0, 350) })),
176
+ rules: playbookRules.map(r => ({ id: r.id, rule: r.rule.slice(0, 350) })),
177
+ mcp_servers: (input.swarm?.mcpServers ?? []).map(s => ({ name: s.name, state: s.state, tools: s.tools })),
178
+ }, questions, { jev: input.jev })
179
+ if (!decision) return null
180
+
181
+ const selected = new Set(messages.map((_, i) => i).filter(i => mandatoryMessages.has(i) ||
182
+ !candidateMessageIds.includes(i) || (probability(decision.answers[`history_${i}`]) ?? 1) >= 0.35))
183
+ // A reply travels with the user turn it answered: Gemini silently drops a
184
+ // model turn that has no preceding user turn.
185
+ for (const i of [...selected]) {
186
+ if (messages[i].role !== "assistant") continue
187
+ for (let j = i - 1; j >= 0; j--) if (messages[j].role === "user") { selected.add(j); break }
188
+ }
189
+ const selectedMessageIds = [...selected].sort((a, b) => a - b)
190
+ const selectedToolNames = candidateTools.filter(t => (probability(decision.answers[`tool_${t.name}`]) ?? 1) >= 0.35).map(t => t.name)
191
+ const selectedSkills = optionalSkills.filter(s => (probability(decision.answers[`skill_${s.id}`]) ?? 1) >= 0.35)
192
+ const toolMap = new Map(allTools.map(t => [t.name, t]))
193
+ const selectedNames = new Set(selectedToolNames)
194
+ const candidateNames = new Set(candidateTools.map(t => t.name))
195
+ const combinedTools = tools.filter(t => !candidateNames.has(t.function.name) || selectedNames.has(t.function.name))
196
+ for (const name of selectedToolNames) {
197
+ const tool = toolMap.get(name)
198
+ if (tool && !combinedTools.some(t => t.function.name === name)) combinedTools.push({
199
+ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters },
200
+ })
201
+ }
202
+ const agentAnswer = decision.answers.agent
203
+ const agentId = agentAnswer?.type === "choice" && agentAnswer.confidence >= 0.7 && agentAnswer.choice !== "coordinator"
204
+ ? agentAnswer.choice : null
205
+ const agentMcpOff = agents.find(a => a.id === agentId)?.mcp.filter(m => m.state === "apagado").map(m => m.name) ?? []
206
+ return {
207
+ messages: selectedMessageIds.map(i => messages[i]), tools: combinedTools,
208
+ skills: selectedSkills, agentId, agentMcpOff, selectedMessageIds, selectedToolNames,
209
+ selectedSkillNames: selectedSkills.map(s => s.name),
210
+ selectedScratchpadKeys: scratchpadNotes.filter(n => (probability(decision.answers[`note_${n.key}`]) ?? 1) >= 0.35).map(n => n.key),
211
+ selectedPlaybookIds: playbookRules.filter(r => (probability(decision.answers[`rule_${r.id}`]) ?? 1) >= 0.35).map(r => r.id),
212
+ decision: { latencyMs: decision.latencyMs, costUsd: decision.costUsd },
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Only independent reads, or separate delegated tasks, may execute concurrently.
218
+ * null leaves the batch to the runtime default; `decision` is present only when Jev was asked.
219
+ */
220
+ export async function jevWantsParallel(
221
+ calls: Array<{ function: { name: string; arguments: unknown } }>,
222
+ jev?: JevOption,
223
+ ): Promise<{ parallel: boolean; decision?: JevDecisionMetrics } | null> {
224
+ if (calls.length < 2) return null
225
+ if (!await getJevKey(jev).catch(() => null)) return null
226
+ const names = calls.map(c => c.function.name)
227
+ const readOnly = names.every(n => /^(fs_read|fs_list|fs_glob|fs_exists|web_search|web_fetch|memory_read|memory_search|artifact_read|artifact_inspect|task_status|agent_find)$/.test(n))
228
+ const delegated = names.every(n => n === "task_delegate")
229
+ if (!readOnly && !delegated) return { parallel: false }
230
+ if (delegated) {
231
+ const ids = calls.map(call => {
232
+ try {
233
+ const args = typeof call.function.arguments === "string" ? JSON.parse(call.function.arguments) : call.function.arguments
234
+ return String(args?.worker_id ?? "")
235
+ } catch { return "" }
236
+ })
237
+ if (ids.some(id => !id) || new Set(ids).size !== ids.length) return { parallel: false }
238
+ const agentsCol = await col<AgentDoc>("agents")
239
+ const agents = await Promise.all(ids.map(id => agentsCol.get(id)))
240
+ const workspaces = agents.map(row => row?.doc.workspace)
241
+ if (workspaces.some(path => !path) || new Set(workspaces).size !== workspaces.length) return { parallel: false }
242
+ }
243
+ const result = await askJev({ calls: calls.map(c => ({ tool: c.function.name, arguments: excerpt(c.function.arguments, 700) })) }, {
244
+ independent: { type: "noul", instructions: "Can every listed operation run concurrently without needing the result of another listed operation?" },
245
+ }, { jev })
246
+ const answer = result?.answers.independent
247
+ return result && answer?.type === "noul"
248
+ ? { parallel: answer.noul >= 0.8, decision: { latencyMs: result.latencyMs, costUsd: result.costUsd } }
249
+ : null
250
+ }
251
+
252
+ export async function planJevIteration(input: {
253
+ objective: string
254
+ messages: LLMMessage[]
255
+ tools: LLMToolDef[]
256
+ jev?: JevOption
257
+ }): Promise<{ messages: LLMMessage[]; tools: LLMToolDef[]; action: string; omittedResults: number; decision: JevDecisionMetrics } | null> {
258
+ const toolIndices = input.messages.map((m, i) => m.role === "tool" ? i : -1).filter(i => i >= 0)
259
+ if (!toolIndices.length) return null
260
+ const older = toolIndices.slice(0, -1).slice(-8)
261
+ const prunableChars = older.reduce((sum, i) => sum + excerpt(input.messages[i].content, Infinity).length, 0)
262
+ if (prunableChars < MIN_PRUNABLE_CHARS) return null
263
+ const questions: Record<string, JevQuestion> = {
264
+ action: {
265
+ type: "choice",
266
+ instructions: "What capability should the text model use next to complete the current objective?",
267
+ criteria: {
268
+ continue: "Continue reasoning with currently available tools",
269
+ delegate: "Formulate a bounded task for an existing specialist",
270
+ discover: "Discover another tool or skill before continuing",
271
+ finish: "The evidence is sufficient to compose the final answer without more tools",
272
+ },
273
+ },
274
+ }
275
+ for (const i of older) questions[`result_${i}`] = {
276
+ type: "noul", instructions: `Is result ${i} still needed to complete the current objective?`,
277
+ }
278
+ const result = await askJev({
279
+ objective: input.objective.slice(0, 2800),
280
+ results: toolIndices.slice(-9).map(i => ({ id: i, tool: input.messages[i].name, content: excerpt(input.messages[i].content, 650) })),
281
+ }, questions, { jev: input.jev })
282
+ if (!result) return null
283
+ const omitted = new Set(older.filter(i => (probability(result.answers[`result_${i}`]) ?? 1) < 0.35))
284
+ const projected = input.messages.map((m, i) => omitted.has(i)
285
+ ? { ...m, content: "[Previous tool result omitted from this call]" } : m)
286
+ const answer = result.answers.action
287
+ const latestResult = input.messages[toolIndices[toolIndices.length - 1]]
288
+ const resultFailed = typeof latestResult.content === "string" && (latestResult.content.startsWith("[Tool Error]") || latestResult.content.includes('"error":true'))
289
+ const proposedAction = answer?.type === "choice" && answer.confidence >= (answer.choice === "finish" ? 0.85 : 0.7) ? answer.choice : "continue"
290
+ const action = proposedAction === "finish" && resultFailed ? "continue" : proposedAction
291
+ let tools = input.tools
292
+ if (action === "finish") tools = []
293
+ else if (action === "delegate") tools = tools.filter(t => ["task_delegate", "agent_find", "search_knowledge"].includes(t.function.name))
294
+ else if (action === "discover") tools = tools.filter(t => t.function.name === "search_knowledge")
295
+ const decision = { latencyMs: result.latencyMs, costUsd: result.costUsd }
296
+ if (action !== "finish" && tools.length === 0) return { messages: projected, tools: input.tools, action: "continue", omittedResults: omitted.size, decision }
297
+ return { messages: projected, tools, action, omittedResults: omitted.size, decision }
298
+ }
@@ -309,7 +309,7 @@ export async function resolveProviderConfig(
309
309
  credentials?: ProviderCredentials
310
310
  ): Promise<Pick<LLMCallOptions, "provider" | "model" | "apiKey" | "baseUrl" | "numCtx" | "numGpu" | "contextWindow">> {
311
311
  const { col } = await import("../storage/hive.ts")
312
- const { loadProviderApiKey } = await import("../storage/crypto.ts")
312
+ const { envSecret, loadProviderApiKey } = await import("../storage/crypto.ts")
313
313
  const providersCol = await col<import("../storage/collections.ts").ProviderDoc>("providers")
314
314
  const modelsCol = await col<import("../storage/collections.ts").ModelDoc>("models")
315
315
 
@@ -327,7 +327,7 @@ export async function resolveProviderConfig(
327
327
  apiKey = await loadProviderApiKey(providerId)
328
328
  }
329
329
  if (!apiKey) {
330
- apiKey = process.env[`${providerId.toUpperCase()}_API_KEY`] || ""
330
+ apiKey = envSecret(`${providerId.toUpperCase()}_API_KEY`) || ""
331
331
  }
332
332
 
333
333
  return {
@@ -1,3 +1,4 @@
1
+ import { envSecret } from "../../storage/crypto.ts"
1
2
  import { logger } from "../../utils/logger.ts"
2
3
  import { normalizeToolName, resolveMaxTokens, ensureArrayItems } from "./interface.ts"
3
4
  import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall, ThinkingBlock } from "./interface.ts"
@@ -74,7 +75,7 @@ export class AnthropicProvider implements LLMProvider {
74
75
 
75
76
  async call(options: LLMCallOptions): Promise<LLMResponse> {
76
77
  const Anthropic = await import("@anthropic-ai/sdk")
77
- const client = new Anthropic.default({ apiKey: options.apiKey })
78
+ const client = new Anthropic.default({ apiKey: options.apiKey || envSecret("ANTHROPIC_API_KEY") || "" })
78
79
 
79
80
  // Anthropic requires tool names to match ^[a-zA-Z0-9_-]{1,128}$
80
81
  // Native Hive tools use dots (e.g. cron.create) which violate this.
@@ -1,3 +1,4 @@
1
+ import { envSecret } from "../../storage/crypto.ts"
1
2
  import { logger } from "../../utils/logger.ts"
2
3
  import { sanitizeMessages, resolveMaxTokens, ensureArrayItems } from "./interface.ts"
3
4
  import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface.ts"
@@ -100,7 +101,8 @@ export class GeminiProvider implements LLMProvider {
100
101
  async call(options: LLMCallOptions): Promise<LLMResponse> {
101
102
  const { GoogleGenAI } = await import("@google/genai")
102
103
 
103
- const clientOpts: any = { apiKey: options.apiKey }
104
+ // A string always: undefined makes @google/genai read GEMINI_API_KEY itself, inside a tenant too.
105
+ const clientOpts: any = { apiKey: options.apiKey || envSecret("GEMINI_API_KEY") || envSecret("GOOGLE_API_KEY") || "" }
104
106
  if (options.baseUrl?.trim()) clientOpts.httpOptions = { baseUrl: options.baseUrl.trim() }
105
107
 
106
108
  const ai = new GoogleGenAI(clientOpts)
@@ -10,6 +10,7 @@
10
10
  * al contexto inicial en 3.x, así que no se usa acá.
11
11
  */
12
12
 
13
+ import { envSecret } from "../../storage/crypto.ts"
13
14
  import { logger } from "../../utils/logger.ts";
14
15
  import { ensureArrayItems } from "../llm-providers/interface.ts";
15
16
  import type {
@@ -106,7 +107,7 @@ export class GeminiLiveProvider implements RealtimeProvider {
106
107
 
107
108
  async connect(options: RealtimeSessionOptions): Promise<RealtimeSession> {
108
109
  const { GoogleGenAI } = await import("@google/genai");
109
- const ai = new GoogleGenAI({ apiKey: options.apiKey });
110
+ const ai = new GoogleGenAI({ apiKey: options.apiKey || envSecret("GEMINI_API_KEY") || envSecret("GOOGLE_API_KEY") || "" });
110
111
  const cb = options.callbacks;
111
112
 
112
113
  const config: Record<string, unknown> = {
@@ -195,6 +195,7 @@ export const CORE_TOOL_CATALOG: ToolDescriptor[] = [
195
195
 
196
196
  // Capability discovery
197
197
  { name: "search_knowledge", description: "Search everything Hive knows: native tools, MCP tools, skills, catalog agents and playbook rules. Spanish keywords: buscar herramienta, descubrir capacidades, qué puedo hacer, buscar skill, buscar conocimiento", category: "core", abstractionLevel: "atomic" },
198
+ { name: "conversation_read", description: "Read earlier messages from this conversation by ID or search text when context is missing. Spanish keywords: recuperar contexto, leer historial, conversación anterior", category: "core", abstractionLevel: "atomic" },
198
199
 
199
200
  // HTTP / REST
200
201
  { name: "api_request", description: "Perform an authorized HTTP request against a REST endpoint and validate the response. Spanish keywords: llamar api, request rest, consumir endpoint, petición http, hacer get, hacer post", category: "api", abstractionLevel: "atomic" },
@@ -15,6 +15,8 @@ export type CanvasEventType =
15
15
  | "canvas:edge_add"
16
16
  | "canvas:edge_remove"
17
17
  | "canvas:work_event"
18
+ | "canvas:jev_decision"
19
+ | "canvas:jev_status"
18
20
  | "ag-ui:event"
19
21
 
20
22
  export type CanvasWorkPhase =
@@ -65,6 +67,23 @@ export function unsubscribeCanvas(ws: { send: (data: string) => void }) {
65
67
  subscribers.delete(ws)
66
68
  }
67
69
 
70
+ /** One Jev decision, shown by the office's oracle as a beam to the agent it advised. */
71
+ export interface CanvasJevDecision {
72
+ eventId: string
73
+ agentId: string
74
+ kind: "context" | "iteration" | "parallel"
75
+ /** Short Spanish description of what was selected ("4/15 mensajes · 9/24 herramientas"). */
76
+ summary: string
77
+ /** Estimated main-model input tokens this decision avoided (chars/4); negative when it added context. */
78
+ savedTokens: number
79
+ latencyMs: number
80
+ costUsd: number
81
+ recommendedAgentId?: string | null
82
+ /** MCP servers the recommended specialist needs that are off; the coordinator asks the user to turn them on. */
83
+ mcpOff?: string[]
84
+ totals: { decisions: number; savedTokens: number; costUsd: number }
85
+ }
86
+
68
87
  export function emitCanvas(type: CanvasEventType, data: any) {
69
88
  // Track live agent state for new subscribers
70
89
  if (type === "canvas:node_update" && data?.nodeId && data?.changes) {
@@ -49,6 +49,7 @@ const KIND_PREFIX: Record<NarrationEventDoc["kind"], string> = {
49
49
  verified: "✅",
50
50
  failed: "❌",
51
51
  group_ready: "📝",
52
+ decision: "🔮",
52
53
  };
53
54
 
54
55
  const DETAIL_MAX_CHARS = 200;
@@ -86,6 +87,8 @@ export async function resolveNarrationMode(channelType: string): Promise<Narrati
86
87
 
87
88
  export function shouldDeliverToChannel(event: NarrationEventDoc, mode: NarrationMode): boolean {
88
89
  if (mode === "off") return false;
90
+ // Internal bookkeeping for activity views, not something a customer reads.
91
+ if (event.kind === "decision") return false;
89
92
  if (MILESTONE_KINDS.has(event.kind)) return true;
90
93
  if (mode !== "all") return false;
91
94
  // Even in `all`, a successful tool_result only restates the tool_call that
@@ -1,6 +1,6 @@
1
1
  import { col } from "../storage/hive.ts"
2
2
  import type { ChannelDoc, ModelDoc, ProviderDoc } from "../storage/collections.ts"
3
- import { loadProviderApiKey } from "../storage/crypto.ts"
3
+ import { envSecret, loadProviderApiKey } from "../storage/crypto.ts"
4
4
  import { logger } from "../utils/logger.ts"
5
5
  import type { ImageInput, DocumentInput, VisionConfig } from "./types.ts"
6
6
  import type { ContentPart } from "./types.ts"
@@ -177,7 +177,7 @@ class MultimodalService {
177
177
  }
178
178
 
179
179
  private async ocrWithOpenAI(image: ImageInput): Promise<string> {
180
- const key = await this.getProviderApiKey("openai") || process.env.OPENAI_API_KEY
180
+ const key = await this.getProviderApiKey("openai") || envSecret("OPENAI_API_KEY")
181
181
  if (!key) throw new Error("OPENAI_API_KEY not configured for OCR")
182
182
 
183
183
  const imageUrl = await this.resolveImageUrl(image)
@@ -208,7 +208,7 @@ class MultimodalService {
208
208
  }
209
209
 
210
210
  private async ocrWithGemini(image: ImageInput): Promise<string> {
211
- const key = await this.getProviderApiKey("gemini") || process.env.GEMINI_API_KEY
211
+ const key = await this.getProviderApiKey("gemini") || envSecret("GEMINI_API_KEY")
212
212
  if (!key) throw new Error("GEMINI_API_KEY not configured for OCR")
213
213
 
214
214
  let imagePart: any
@@ -243,7 +243,7 @@ class MultimodalService {
243
243
  }
244
244
 
245
245
  private async ocrWithAnthropic(image: ImageInput): Promise<string> {
246
- const key = await this.getProviderApiKey("anthropic") || process.env.ANTHROPIC_API_KEY
246
+ const key = await this.getProviderApiKey("anthropic") || envSecret("ANTHROPIC_API_KEY")
247
247
  if (!key) throw new Error("ANTHROPIC_API_KEY not configured for OCR")
248
248
 
249
249
  const imageUrl = await this.resolveImageUrl(image)
@@ -299,9 +299,9 @@ class MultimodalService {
299
299
  ])
300
300
 
301
301
  return {
302
- openai: openai || !!(process.env.OPENAI_API_KEY),
303
- gemini: gemini || !!(process.env.GEMINI_API_KEY),
304
- anthropic: anthropic || !!(process.env.ANTHROPIC_API_KEY),
302
+ openai: openai || !!(envSecret("OPENAI_API_KEY")),
303
+ gemini: gemini || !!(envSecret("GEMINI_API_KEY")),
304
+ anthropic: anthropic || !!(envSecret("ANTHROPIC_API_KEY")),
305
305
  }
306
306
  }
307
307
 
@@ -18,6 +18,7 @@ import { col } from "../storage/hive.ts";
18
18
  import type { SwarmDoc, SwarmMemberSpec, AgentDoc } from "../storage/collections.ts";
19
19
  import { runRoleSwarm, type RoleSwarmResult, type SwarmMessage } from "../swarm/RoleSwarm.ts";
20
20
  import type { ProviderCredentials } from "../agent/llm-client.ts";
21
+ import type { JevOption } from "../agent/jev-decisions.ts";
21
22
  import { slugify } from "./agents.ts";
22
23
  import { logger } from "../utils/logger.ts";
23
24
  import { enableCatalogAgents, planActivationFor, CATALOG_AGENT_IDS, type ActivationGap } from "./setup.ts";
@@ -268,6 +269,8 @@ export interface RunSwarmOptions {
268
269
  channel?: string;
269
270
  /** Credenciales del inquilino, propagadas a cada agente. */
270
271
  credentials?: ProviderCredentials;
272
+ /** Jev del inquilino, propagado a cada agente igual que `credentials`. */
273
+ jev?: JevOption;
271
274
  signal?: AbortSignal;
272
275
  /** Se llama en cada paso; acá persiste el consumidor si quiere. */
273
276
  onMessage?: (message: SwarmMessage) => void | Promise<void>;
@@ -301,6 +304,7 @@ export async function runSwarm(
301
304
  orchestratorAgentId: swarm.orchestratorAgentId ?? undefined,
302
305
  maxDelegations: swarm.maxDelegations ?? undefined,
303
306
  credentials: opts?.credentials,
307
+ jev: opts?.jev,
304
308
  signal: opts?.signal,
305
309
  onMessage: opts?.onMessage,
306
310
  });
@@ -41,7 +41,7 @@ export interface ModelDoc {
41
41
  provider_id: string
42
42
  name: string
43
43
  /** "realtime": voz full-duplex (Live API), no confundir con stt+tts encadenados. */
44
- model_type: "llm" | "stt" | "tts" | "vision" | "embedding" | "realtime"
44
+ model_type: "llm" | "stt" | "tts" | "vision" | "embedding" | "realtime" | "decision"
45
45
  context_window: number
46
46
  capabilities: string | null
47
47
  enabled: boolean
@@ -741,7 +741,8 @@ export interface NarrationEventDoc {
741
741
  session_id: string
742
742
  agent_id: string
743
743
  agent_name: string
744
- kind: "delegated" | "worker_started" | "tool_call" | "tool_result" | "verified" | "failed" | "group_ready"
744
+ /** "decision": a Jev decision, recorded for the host's activity views; never delivered to a channel. */
745
+ kind: "delegated" | "worker_started" | "tool_call" | "tool_result" | "verified" | "failed" | "group_ready" | "decision"
745
746
  status: "queued" | "running" | "done" | "error"
746
747
  label: string
747
748
  detail: string | null
@@ -809,6 +810,12 @@ export interface UsageRollupDoc {
809
810
  toonJsonBytes: number
810
811
  byProvider: Record<string, { inputTokens: number; outputTokens: number; costUsd: number }>
811
812
  byModel: Record<string, { inputTokens: number; outputTokens: number; costUsd: number }>
813
+ /** Jev decision plane; absent on hours before it existed. Savings are estimates (chars/4) priced at the advised agent's model. */
814
+ jevDecisions?: number
815
+ jevCostUsd?: number
816
+ jevSavedTokens?: number
817
+ jevSavedCostUsd?: number
818
+ jevByAgent?: Record<string, { jevDecisions: number; jevCostUsd: number; jevSavedTokens: number; jevSavedCostUsd: number }>
812
819
  }
813
820
 
814
821
  export interface ActivityRollupDoc {
@@ -4,6 +4,7 @@ import * as path from "node:path"
4
4
  import { getHiveDir } from "../config/loader.ts"
5
5
  import { logger } from "../utils/logger.ts"
6
6
  import { col } from "./hive.ts"
7
+ import { currentTenant } from "./tenant.ts"
7
8
 
8
9
  const log = logger.child("crypto")
9
10
  const SERVICE = "hive"
@@ -25,8 +26,28 @@ interface SecretDoc {
25
26
  // written *only* there does not survive a server restart. That is exactly
26
27
  // what was wiping every provider API key, channel token and MCP header on
27
28
  // restart in production.
28
-
29
+ //
30
+ // Multi-tenant: the `secrets` collection is partitioned by tenant through
31
+ // `col()`, but this process's memory and the OS keychain are not. So the
32
+ // in-memory cache is keyed by tenant as well, and with a tenant in scope the
33
+ // keychain is never read or written — a machine-wide entry named
34
+ // `provider:openai:api_key` belongs to no tenant in particular, and serving it
35
+ // (or overwriting it) on a tenant's behalf would hand one customer another's
36
+ // key.
37
+
38
+ /** Decrypted values, keyed by {@link _cacheKey}: never shared across tenants. */
29
39
  const _mem = new Map<string, string>()
40
+
41
+ /** `name` for the desktop (no tenant); `<tenant>\0name` inside a tenant. */
42
+ function _cacheKey(name: string): string {
43
+ const tenant = currentTenant()
44
+ return tenant ? `${tenant}\0${name}` : name
45
+ }
46
+
47
+ /** The OS keychain only serves the tenant-less (desktop) scope. */
48
+ function _keychainInScope(): boolean {
49
+ return currentTenant() === null
50
+ }
30
51
  let _keychainOk: boolean | null = null // null = untested
31
52
 
32
53
  let _keychainApi: unknown = undefined
@@ -46,16 +67,21 @@ function _getKeychainApi(): any {
46
67
  }
47
68
 
48
69
  async function _get(name: string): Promise<string | null> {
49
- const cached = _mem.get(name)
70
+ const cacheKey = _cacheKey(name)
71
+ const cached = _mem.get(cacheKey)
50
72
  if (cached !== undefined) return cached
51
73
 
52
74
  // Durable store first — it is the one every write goes to.
53
75
  const stored = await _readCollectionSecret(name)
54
- if (stored) return stored
76
+ if (stored) {
77
+ _mem.set(cacheKey, stored)
78
+ return stored
79
+ }
55
80
 
56
81
  // Legacy/desktop installs may only have the value in the OS keychain.
82
+ if (!_keychainInScope()) return null
57
83
  const fromKeychain = await _keychainGet(name)
58
- if (fromKeychain) _mem.set(name, fromKeychain)
84
+ if (fromKeychain) _mem.set(cacheKey, fromKeychain)
59
85
  return fromKeychain
60
86
  }
61
87
 
@@ -66,9 +92,9 @@ async function _get(name: string): Promise<string | null> {
66
92
  * silently accepting a secret that dies with the process.
67
93
  */
68
94
  async function _set(name: string, value: string): Promise<boolean> {
69
- _mem.set(name, value)
95
+ _mem.set(_cacheKey(name), value)
70
96
  const durable = await persistSecretToCollection(name, value)
71
- const mirrored = await _keychainSet(name, value)
97
+ const mirrored = _keychainInScope() ? await _keychainSet(name, value) : false
72
98
  if (!durable && !mirrored) {
73
99
  log.error(`[secrets] ${name} could not be persisted — it will be lost on restart`)
74
100
  }
@@ -84,12 +110,7 @@ async function _readCollectionSecret(name: string): Promise<string | null> {
84
110
  const secrets = await col<SecretDoc>("secrets")
85
111
  const entry = await secrets.get(name)
86
112
  if (!entry) return null
87
- const plain = decryptSecret(entry.doc.ciphertext, entry.doc.iv)
88
- if (plain) {
89
- // Cache in memory for subsequent lookups in this process
90
- _mem.set(name, plain)
91
- }
92
- return plain || null
113
+ return decryptSecret(entry.doc.ciphertext, entry.doc.iv) || null
93
114
  } catch {
94
115
  return null
95
116
  }
@@ -135,11 +156,13 @@ async function _keychainSet(name: string, value: string): Promise<boolean> {
135
156
  }
136
157
 
137
158
  async function _del(name: string): Promise<void> {
138
- _mem.delete(name)
139
- try {
140
- await (Bun as any).secrets.delete({ service: SERVICE, name })
141
- } catch {
142
- // ignore — might not exist or keychain unavailable
159
+ _mem.delete(_cacheKey(name))
160
+ if (_keychainInScope()) {
161
+ try {
162
+ await (Bun as any).secrets.delete({ service: SERVICE, name })
163
+ } catch {
164
+ // ignore — might not exist or keychain unavailable
165
+ }
143
166
  }
144
167
  try {
145
168
  const secrets = await col<SecretDoc>("secrets")
@@ -195,6 +218,17 @@ export async function loadProviderApiKey(id: string): Promise<string> {
195
218
  return (await _get(`provider:${id}:api_key`)) ?? ""
196
219
  }
197
220
 
221
+ /**
222
+ * A credential from the process environment (`OPENAI_API_KEY`, …) — only
223
+ * outside a tenant. The environment is the host's: inside a tenant it belongs
224
+ * to the platform, not the customer, and using it would bill the platform's
225
+ * account for a customer's call (or let one customer run on another's key).
226
+ * A tenant's credentials come from its own secrets or from `credentials`.
227
+ */
228
+ export function envSecret(name: string): string | undefined {
229
+ return currentTenant() ? undefined : process.env[name] || undefined
230
+ }
231
+
198
232
  export async function storeProviderHeaders(id: string, headers: Record<string, unknown>): Promise<boolean> {
199
233
  return await _set(`provider:${id}:headers`, JSON.stringify(headers))
200
234
  }