@johpaz/hive-sdk 0.4.9 → 0.5.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.
@@ -0,0 +1,199 @@
1
+ /** Optional decision plane. Jev is never used through the chat completions API. */
2
+ import { col } from "../storage/hive.ts"
3
+ import type { ProviderDoc } from "../storage/collections.ts"
4
+ import { loadDurableProviderApiKey, loadProviderApiKey } from "../storage/crypto.ts"
5
+ import { recordJevDecision, recordUsage } from "../storage/usage.ts"
6
+ import { catalogModelKey } from "../storage/model-id.ts"
7
+ import { currentTenant } from "../storage/tenant.ts"
8
+ import { logger } from "../utils/logger.ts"
9
+ import { emitCanvas, type CanvasJevDecision } from "../canvas/emitter.ts"
10
+
11
+ const log = logger.child("jev-decisions")
12
+ export const JEV_MODEL = "typesafe/jev-1.13"
13
+ const ENDPOINT = "https://openrouter.ai/api/alpha/decisions"
14
+ const TIMEOUT_MS = 3000
15
+ const COOLDOWN_MS = 60_000
16
+ /** Where the user turns an MCP server on, when the host does not say otherwise. */
17
+ export const DEFAULT_JEV_MCP_SETTINGS_PATH = "Ajustes → Entorno → MCP Servers"
18
+ let decisionSequence = 0
19
+
20
+ /**
21
+ * How a caller controls Jev for one run.
22
+ *
23
+ * - `{ apiKey }`: use this OpenRouter key. A multi-tenant host resolves its
24
+ * tenant's key and passes it here, exactly like `credentials`.
25
+ * `mcpSettingsPath` names where that host's users turn an MCP server on.
26
+ * - `false`: Jev is off for this run; nothing is sent to OpenRouter.
27
+ * - `undefined`: the `openrouter` provider row of the current tenant decides.
28
+ */
29
+ export type JevOption = { apiKey: string; mcpSettingsPath?: string } | false
30
+
31
+ /**
32
+ * Failure and cooldown bookkeeping, per tenant: one tenant's invalid key must
33
+ * not put every other tenant in the same process into fallback.
34
+ */
35
+ interface JevTenantState {
36
+ failures: number
37
+ cooldownUntil: number
38
+ lastError: string | null
39
+ lastSuccessAt: number | null
40
+ /** Since process start; the office shows them as the oracle's running contribution. */
41
+ totals: { decisions: number; savedTokens: number; costUsd: number }
42
+ }
43
+
44
+ const states = new Map<string, JevTenantState>()
45
+
46
+ function tenantState(): JevTenantState {
47
+ const key = currentTenant() ?? "default"
48
+ let state = states.get(key)
49
+ if (!state) {
50
+ state = { failures: 0, cooldownUntil: 0, lastError: null, lastSuccessAt: null, totals: { decisions: 0, savedTokens: 0, costUsd: 0 } }
51
+ states.set(key, state)
52
+ }
53
+ return state
54
+ }
55
+
56
+ export type JevQuestion =
57
+ | { type: "choice"; instructions: string; criteria: Record<string, string> }
58
+ | { type: "noul"; instructions: string; criteria?: { true: string; false: string } }
59
+
60
+ export type JevAnswer =
61
+ | { type: "choice"; choice: string; confidence: number; probabilities: Record<string, number> }
62
+ | { type: "noul"; noul: number }
63
+
64
+ export interface JevResult {
65
+ answers: Record<string, JevAnswer>
66
+ inputTokens: number
67
+ costUsd: number
68
+ latencyMs: number
69
+ }
70
+
71
+ /**
72
+ * The OpenRouter key Jev would use, or null when Jev is off.
73
+ *
74
+ * With a tenant in scope the key comes only from that tenant's `secrets`
75
+ * partition — never from `OPENROUTER_API_KEY`, which is the platform's, nor
76
+ * from the process-wide secret cache, which is not partitioned.
77
+ */
78
+ export async function getJevKey(option?: JevOption): Promise<string | null> {
79
+ if (option === false) return null
80
+ if (option) return option.apiKey || null
81
+ const provider = await (await col<ProviderDoc>("providers")).get("openrouter")
82
+ if (!provider?.doc.enabled || !provider.doc.active) return null
83
+ if (currentTenant()) return (await loadDurableProviderApiKey("openrouter")) || null
84
+ return (await loadProviderApiKey("openrouter")) || process.env.OPENROUTER_API_KEY || null
85
+ }
86
+
87
+ export interface JevStatus {
88
+ state: "off" | "ready" | "fallback"
89
+ lastError: string | null
90
+ lastSuccessAt: number | null
91
+ totals: { decisions: number; savedTokens: number; costUsd: number }
92
+ }
93
+
94
+ export async function getJevStatus(option?: JevOption): Promise<JevStatus> {
95
+ const key = await getJevKey(option).catch(() => null)
96
+ const state = tenantState()
97
+ return {
98
+ state: !key ? "off" : Date.now() < state.cooldownUntil || state.lastError ? "fallback" : "ready",
99
+ lastError: key ? state.lastError : null,
100
+ lastSuccessAt: key ? state.lastSuccessAt : null,
101
+ totals: { ...state.totals },
102
+ }
103
+ }
104
+
105
+ function broadcastStatus(option?: JevOption): void {
106
+ getJevStatus(option).then(status => emitCanvas("canvas:jev_status", status)).catch(() => { /* best effort */ })
107
+ }
108
+
109
+ /**
110
+ * Publishes a served decision to the office and persists it for the dashboard.
111
+ * Callers estimate savings; Jev itself only answers questions. `provider`/`model`
112
+ * are the advised agent's main model, used to price the avoided tokens.
113
+ * Returns the published event so the caller can forward it to its own host.
114
+ */
115
+ export function emitJevDecision({ provider, model, ...decision }: Omit<CanvasJevDecision, "eventId" | "totals"> & { provider: string; model: string }): CanvasJevDecision {
116
+ recordJevDecision({ agentId: decision.agentId, provider, model, savedTokens: decision.savedTokens, costUsd: decision.costUsd })
117
+ const { totals } = tenantState()
118
+ totals.decisions++
119
+ totals.savedTokens += decision.savedTokens
120
+ totals.costUsd += decision.costUsd
121
+ const event = {
122
+ ...decision,
123
+ eventId: `jev:${Date.now().toString(36)}:${++decisionSequence}`,
124
+ summary: decision.summary.slice(0, 160),
125
+ totals: { ...totals },
126
+ } satisfies CanvasJevDecision
127
+ emitCanvas("canvas:jev_decision", event)
128
+ return event
129
+ }
130
+
131
+ /** Clears the current tenant's failure state (after its key changed, for instance). */
132
+ export function resetJevStatus(option?: JevOption): void {
133
+ const state = tenantState()
134
+ state.failures = 0
135
+ state.cooldownUntil = 0
136
+ state.lastError = null
137
+ state.lastSuccessAt = null
138
+ broadcastStatus(option)
139
+ }
140
+
141
+ export async function askJev(
142
+ state: unknown,
143
+ questions: Record<string, JevQuestion>,
144
+ options: { fetcher?: typeof fetch; signal?: AbortSignal; jev?: JevOption } = {},
145
+ ): Promise<JevResult | null> {
146
+ const key = await getJevKey(options.jev).catch(() => null)
147
+ const tenant = tenantState()
148
+ if (!key || Date.now() < tenant.cooldownUntil || Object.keys(questions).length === 0) return null
149
+ const started = performance.now()
150
+ try {
151
+ const response = await (options.fetcher ?? fetch)(ENDPOINT, {
152
+ method: "POST",
153
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
154
+ body: JSON.stringify({ model: JEV_MODEL, state, questions }),
155
+ signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(TIMEOUT_MS)]) : AbortSignal.timeout(TIMEOUT_MS),
156
+ })
157
+ if (!response.ok) {
158
+ if (response.status === 401 || response.status === 403) tenant.cooldownUntil = Date.now() + COOLDOWN_MS
159
+ throw new Error(`OpenRouter HTTP ${response.status}`)
160
+ }
161
+ const data = await response.json() as {
162
+ answers?: Record<string, JevAnswer>
163
+ usage?: { input_tokens?: number; output_tokens?: number; cost?: number }
164
+ }
165
+ const answers: Record<string, JevAnswer> = {}
166
+ for (const [name, question] of Object.entries(questions)) {
167
+ const answer = data.answers?.[name]
168
+ if (question.type === "choice") {
169
+ if (answer?.type !== "choice" || !Object.hasOwn(question.criteria, answer.choice) ||
170
+ !Number.isFinite(answer.confidence) || answer.confidence < 0 || answer.confidence > 1) {
171
+ throw new Error(`Invalid choice answer: ${name}`)
172
+ }
173
+ } else if (answer?.type !== "noul" || !Number.isFinite(answer.noul) || answer.noul < 0 || answer.noul > 1) {
174
+ throw new Error(`Invalid noul answer: ${name}`)
175
+ }
176
+ answers[name] = answer
177
+ }
178
+ const recovered = tenant.lastError !== null
179
+ tenant.failures = 0
180
+ tenant.lastError = null
181
+ tenant.lastSuccessAt = Date.now()
182
+ if (recovered) broadcastStatus(options.jev)
183
+ const inputTokens = data.usage?.input_tokens ?? 0
184
+ const costUsd = data.usage?.cost ?? inputTokens * 0.042 / 1_000_000
185
+ log.info(`Decision served: questions=${Object.keys(questions).join(",")} latency_ms=${Math.round(performance.now() - started)} input_tokens=${inputTokens} cost_usd=${costUsd}`)
186
+ if (inputTokens > 0) {
187
+ recordUsage({ provider: "openrouter", model: catalogModelKey("openrouter", JEV_MODEL), inputTokens, outputTokens: data.usage?.output_tokens ?? 0, latencyMs: Math.round(performance.now() - started) })
188
+ }
189
+ return { answers, inputTokens, costUsd, latencyMs: Math.round(performance.now() - started) }
190
+ } catch (error) {
191
+ if (options.signal?.aborted) return null
192
+ tenant.failures++
193
+ tenant.lastError = error instanceof Error ? error.message : "Jev unavailable"
194
+ if (tenant.failures >= 3) tenant.cooldownUntil = Date.now() + COOLDOWN_MS
195
+ log.warn(`Decision fallback: ${tenant.lastError}`)
196
+ broadcastStatus(options.jev)
197
+ return null
198
+ }
199
+ }
@@ -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
+ }
@@ -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
@@ -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 {
@@ -51,7 +51,10 @@ async function _get(name: string): Promise<string | null> {
51
51
 
52
52
  // Durable store first — it is the one every write goes to.
53
53
  const stored = await _readCollectionSecret(name)
54
- if (stored) return stored
54
+ if (stored) {
55
+ _mem.set(name, stored)
56
+ return stored
57
+ }
55
58
 
56
59
  // Legacy/desktop installs may only have the value in the OS keychain.
57
60
  const fromKeychain = await _keychainGet(name)
@@ -84,12 +87,8 @@ async function _readCollectionSecret(name: string): Promise<string | null> {
84
87
  const secrets = await col<SecretDoc>("secrets")
85
88
  const entry = await secrets.get(name)
86
89
  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
90
+ // `_get` caches it; `loadDurableProviderApiKey` must not.
91
+ return decryptSecret(entry.doc.ciphertext, entry.doc.iv) || null
93
92
  } catch {
94
93
  return null
95
94
  }
@@ -195,6 +194,15 @@ export async function loadProviderApiKey(id: string): Promise<string> {
195
194
  return (await _get(`provider:${id}:api_key`)) ?? ""
196
195
  }
197
196
 
197
+ /**
198
+ * The provider key from the durable `secrets` collection only. That collection
199
+ * is partitioned by tenant; the in-memory cache and the OS keychain are not, so
200
+ * a multi-tenant caller that must never see another tenant's key reads here.
201
+ */
202
+ export async function loadDurableProviderApiKey(id: string): Promise<string> {
203
+ return (await _readCollectionSecret(`provider:${id}:api_key`)) ?? ""
204
+ }
205
+
198
206
  export async function storeProviderHeaders(id: string, headers: Record<string, unknown>): Promise<boolean> {
199
207
  return await _set(`provider:${id}:headers`, JSON.stringify(headers))
200
208
  }