@johpaz/hive-sdk 0.4.8 → 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,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
  }
@@ -117,6 +117,7 @@ export const SEED_DATA: SeedData = {
117
117
  { id: "notify", name: "notify", category: "core", description: "Enviar notificación al usuario. Sinónimos: notificar, enviar notificación, alertar, aviso" },
118
118
  { id: "save_note", name: "save_note", category: "core", description: "Guardar nota persistente en el scratchpad. Sinónimos: guardar nota, escribir nota, recordatorio rápido, apuntar" },
119
119
  { id: "report_progress", name: "report_progress", category: "core", description: "Reportar progreso actual al usuario. Sinónimos: reportar progreso, informar estado, actualizar progreso, porcentaje" },
120
+ { id: "conversation_read", name: "conversation_read", category: "core", description: "Recuperar mensajes anteriores de esta conversación por ID o búsqueda de texto. Sinónimos: leer conversación, recuperar contexto, historial omitido" },
120
121
 
121
122
  // ─────────────────────────────────────────
122
123
  // 10. OFFICE — Archivos Office (PDF, DOCX, XLSX, PPTX)
@@ -235,6 +236,9 @@ export const SEED_DATA: SeedData = {
235
236
  { id: "kimi-k2.6", providerId: "kimi", name: "Kimi K2.6", modelType: "llm", contextWindow: 262144, capabilities: JSON.stringify(["chat", "vision", "json_mode", "function_calling", "streaming", "code"]), inputPer1M: 0.6, outputPer1M: 3.41 },
236
237
 
237
238
  // ── OpenRouter (fuente: GET https://openrouter.ai/api/v1/models) ──
239
+ // Jev: modelo de decisiones (API Decisions, nunca chat). `modelType:
240
+ // "decision"` lo deja fuera de getDefaultLLM y de get_available_models.
241
+ { id: "typesafe/jev-1.13", providerId: "openrouter", name: "Jev 1.13 (Decisions)", modelType: "decision", contextWindow: 32000, capabilities: JSON.stringify(["choice", "noul", "score"]), inputPer1M: 0.042, outputPer1M: 0 },
238
242
  // Solo modelos vivos con `tools` en supported_parameters y publicados desde
239
243
  // 2025-07. contextWindow = context_length reportado por el propio catálogo,
240
244
  // y los precios también salen de ahí — son los de la ruta de OpenRouter, no
@@ -154,6 +154,15 @@ export interface UsageRecord {
154
154
  created_at: number;
155
155
  }
156
156
 
157
+ export interface JevUsageSummary {
158
+ decisions: number;
159
+ costUsd: number;
160
+ /** Estimated main-model input tokens avoided; negative when Jev added more than it removed. */
161
+ savedTokens: number;
162
+ savedCostUsd: number;
163
+ byAgent: Record<string, { decisions: number; costUsd: number; savedTokens: number; savedCostUsd: number }>;
164
+ }
165
+
157
166
  export interface UsageSummary {
158
167
  totalTokens: number;
159
168
  totalInputTokens: number;
@@ -169,6 +178,7 @@ export interface UsageSummary {
169
178
  byProvider: Record<string, { tokens: number; costUsd: number; inputTokens: number; outputTokens: number }>;
170
179
  byModel: Record<string, { tokens: number; costUsd: number; provider: string; inputTokens: number; outputTokens: number }>;
171
180
  recentRecords: UsageRecord[];
181
+ jev: JevUsageSummary;
172
182
  }
173
183
 
174
184
  export function recordUsage(options: {
@@ -233,13 +243,15 @@ export async function getUsageStats(hours: number = 24): Promise<UsageSummary> {
233
243
  const rollupsCol = await col<UsageRollupDoc>("usageRollups");
234
244
  const buckets = hourBucketsSince(hours);
235
245
  const rollups = (await Promise.all(buckets.map((id) => rollupsCol.get(id))))
236
- .map((e) => e?.doc ?? emptyRollup());
246
+ // A bucket touched only by recordJevDecision lacks the token fields.
247
+ .map((e) => ({ ...emptyRollup(), ...e?.doc }));
237
248
 
238
249
  const providerMap: UsageSummary["byProvider"] = {};
239
250
  const modelMap: UsageSummary["byModel"] = {};
240
251
  let totalInput = 0, totalOutput = 0, totalCost = 0;
241
252
  let toonSavedTokens = 0, toonSavedCost = 0, toonSavedBytes = 0;
242
253
  let toonJsonTokens = 0, toonToonTokens = 0, toonJsonBytes = 0;
254
+ const jev: JevUsageSummary = { decisions: 0, costUsd: 0, savedTokens: 0, savedCostUsd: 0, byAgent: {} };
243
255
 
244
256
  for (const r of rollups) {
245
257
  totalInput += r.inputTokens;
@@ -251,6 +263,18 @@ export async function getUsageStats(hours: number = 24): Promise<UsageSummary> {
251
263
  toonJsonTokens += r.toonJsonTokens;
252
264
  toonToonTokens += r.toonToonTokens;
253
265
  toonJsonBytes += r.toonJsonBytes;
266
+ jev.decisions += r.jevDecisions ?? 0;
267
+ jev.costUsd += r.jevCostUsd ?? 0;
268
+ jev.savedTokens += r.jevSavedTokens ?? 0;
269
+ jev.savedCostUsd += r.jevSavedCostUsd ?? 0;
270
+ for (const [agentId, a] of Object.entries(r.jevByAgent ?? {})) {
271
+ const cur = jev.byAgent[agentId] ?? { decisions: 0, costUsd: 0, savedTokens: 0, savedCostUsd: 0 };
272
+ cur.decisions += a.jevDecisions ?? 0;
273
+ cur.costUsd += a.jevCostUsd ?? 0;
274
+ cur.savedTokens += a.jevSavedTokens ?? 0;
275
+ cur.savedCostUsd += a.jevSavedCostUsd ?? 0;
276
+ jev.byAgent[agentId] = cur;
277
+ }
254
278
 
255
279
  for (const [provider, p] of Object.entries(r.byProvider ?? {})) {
256
280
  const cur = providerMap[provider] ?? { tokens: 0, costUsd: 0, inputTokens: 0, outputTokens: 0 };
@@ -301,10 +325,39 @@ export async function getUsageStats(hours: number = 24): Promise<UsageSummary> {
301
325
  toonSavingsPercent,
302
326
  byProvider: providerMap,
303
327
  byModel: modelMap,
304
- recentRecords
328
+ recentRecords,
329
+ jev,
305
330
  };
306
331
  }
307
332
 
333
+ /**
334
+ * Persists one Jev decision into the hourly rollup. The avoided tokens are
335
+ * priced with the advised agent's own model (input rate), through the same
336
+ * catalog lookup as real usage, so "saved" and "spent" are comparable.
337
+ */
338
+ export function recordJevDecision(options: {
339
+ agentId: string;
340
+ provider: string;
341
+ model: string;
342
+ savedTokens: number;
343
+ costUsd: number;
344
+ }): void {
345
+ Promise.resolve().then(async () => {
346
+ try {
347
+ const unitCost = await calculateCost(options.provider, options.model, Math.abs(options.savedTokens), 0);
348
+ const savedCostUsd = Math.sign(options.savedTokens) * unitCost;
349
+ await bumpRollup("usageRollups", hourBucket(Date.now()), {
350
+ jevDecisions: 1,
351
+ jevCostUsd: options.costUsd,
352
+ jevSavedTokens: options.savedTokens,
353
+ jevSavedCostUsd: savedCostUsd,
354
+ }, { field: "jevByAgent", key: options.agentId });
355
+ } catch (error) {
356
+ log.warn(`[JEV] Failed to record decision:`, error);
357
+ }
358
+ });
359
+ }
360
+
308
361
  /**
309
362
  * Record TOON savings for metrics tracking
310
363
  * This updates the usage_records table with complete TOON compression metrics
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { runAgentIsolated } from "../agent/agent-loop.ts"
24
+ import type { JevOption } from "../agent/jev-decisions.ts"
24
25
  import type { ProviderCredentials } from "../agent/llm-client.ts"
25
26
  import { logger } from "../utils/logger.ts"
26
27
 
@@ -50,6 +51,7 @@ export type AgentInvoker = (input: {
50
51
  threadId: string
51
52
  channel?: string
52
53
  credentials?: ProviderCredentials
54
+ jev?: JevOption
53
55
  signal?: AbortSignal
54
56
  }) => Promise<string>
55
57
 
@@ -60,6 +62,7 @@ export const defaultInvoker: AgentInvoker = async (input) =>
60
62
  threadId: input.threadId,
61
63
  channel: input.channel,
62
64
  credentials: input.credentials,
65
+ jev: input.jev,
63
66
  signal: input.signal,
64
67
  })
65
68
 
@@ -83,6 +86,8 @@ export interface RoleSwarmOptions {
83
86
  maxDelegations?: number
84
87
  /** Credenciales del inquilino, propagadas a cada agente. */
85
88
  credentials?: ProviderCredentials
89
+ /** Jev del inquilino, propagado a cada agente igual que `credentials`. */
90
+ jev?: JevOption
86
91
  signal?: AbortSignal
87
92
  /** Se llama en cada paso; acá persiste el consumidor si quiere. */
88
93
  onMessage?: (message: SwarmMessage) => void | Promise<void>
@@ -129,6 +134,7 @@ export async function runRoleSwarm(opts: RoleSwarmOptions): Promise<RoleSwarmRes
129
134
  threadId,
130
135
  channel: opts.channel,
131
136
  credentials: opts.credentials,
137
+ jev: opts.jev,
132
138
  signal: opts.signal,
133
139
  })
134
140
  }