@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.
- package/CHANGELOG.md +85 -1
- package/README.md +9 -4
- package/docs/API-AGENTS.md +70 -0
- package/docs/INDEX.md +5 -1
- package/docs/UPGRADING.md +20 -0
- package/package.json +1 -1
- package/packages/core/src/agent/agent-loop.ts +82 -5
- package/packages/core/src/agent/context-compiler.ts +122 -23
- package/packages/core/src/agent/index.ts +2 -0
- package/packages/core/src/agent/jev-decisions.ts +197 -0
- package/packages/core/src/agent/jev-planner.ts +298 -0
- package/packages/core/src/agent/llm-client.ts +2 -2
- package/packages/core/src/agent/llm-providers/anthropic.ts +2 -1
- package/packages/core/src/agent/llm-providers/gemini.ts +3 -1
- package/packages/core/src/agent/realtime-providers/gemini-live.ts +2 -1
- package/packages/core/src/agent/tool-selector.ts +1 -0
- package/packages/core/src/canvas/emitter.ts +19 -0
- package/packages/core/src/events/channel-narration.ts +3 -0
- package/packages/core/src/multimodal/vision-service.ts +7 -7
- package/packages/core/src/services/swarms.ts +4 -0
- package/packages/core/src/storage/collections.ts +9 -2
- package/packages/core/src/storage/crypto.ts +51 -17
- package/packages/core/src/storage/index.ts +1 -0
- package/packages/core/src/storage/seed.ts +4 -0
- package/packages/core/src/storage/usage.ts +55 -2
- package/packages/core/src/swarm/RoleSwarm.ts +6 -0
- package/packages/core/src/tool-runtime/index.ts +20 -1
- package/packages/core/src/tools/agents/get-available-models.ts +3 -1
- package/packages/core/src/tools/core/index.ts +33 -2
- package/packages/core/src/tools/web/computer-use.ts +2 -2
- package/packages/core/src/voice/index.ts +13 -13
|
@@ -43,6 +43,8 @@ import { listCatalogAgents, renderAgentRoutingCatalog } from "./catalog-selector
|
|
|
43
43
|
import { expandToolAllowlist } from "./delegation-runtime.ts"
|
|
44
44
|
import { MINIMAL_TOOLS } from "./minimal-loadout.ts"
|
|
45
45
|
import { normalizeMcpResult } from "./mcp-result-normalizer.ts"
|
|
46
|
+
import { describeSwarmCapabilities, planJevContext, renderSpecialistLine } from "./jev-planner.ts"
|
|
47
|
+
import { DEFAULT_JEV_MCP_SETTINGS_PATH, getJevKey, type JevOption } from "./jev-decisions.ts"
|
|
46
48
|
|
|
47
49
|
const log = logger.child("context-compiler")
|
|
48
50
|
|
|
@@ -89,6 +91,8 @@ export interface CompiledContext {
|
|
|
89
91
|
tools: LLMToolDef[]
|
|
90
92
|
allTools: ContextTool[]
|
|
91
93
|
skills: SkillDescriptor[] // Skills loaded (minimal + discovered)
|
|
94
|
+
/** Jev's context plan, for the caller to publish once it knows the agent's resolved model. */
|
|
95
|
+
jevDecision?: { summary: string; savedTokens: number; latencyMs: number; costUsd: number; recommendedAgentId: string | null; mcpOff: string[] }
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
// ─── G9 causal context (buildAgentContext) ────────────────────────────────
|
|
@@ -166,6 +170,10 @@ export async function compileContext(opts: {
|
|
|
166
170
|
mcpManager?: MCPClientManager | null
|
|
167
171
|
/** G9 causal stream id for this invocation (agent-loop.ts's causalStreamId). */
|
|
168
172
|
causalStreamId?: string
|
|
173
|
+
/** A resumed run restores the exact previously selected prompt and loadout. */
|
|
174
|
+
skipJev?: boolean
|
|
175
|
+
/** Jev for this run: a key, `false` for off, or undefined for the tenant's `openrouter` row. */
|
|
176
|
+
jev?: JevOption
|
|
169
177
|
}): Promise<CompiledContext> {
|
|
170
178
|
const { agentId, threadId, mcpManager, userMessage, isolated, taskContext } = opts
|
|
171
179
|
|
|
@@ -521,7 +529,52 @@ export async function compileContext(opts: {
|
|
|
521
529
|
// En el historial las imágenes son referencias, para no reenviarlas enteras en
|
|
522
530
|
// cada turno. Las de los últimos mensajes se vuelven a poner en línea: el
|
|
523
531
|
// modelo todavía puede necesitar mirarlas, y una referencia no se mira.
|
|
524
|
-
|
|
532
|
+
let messages: LLMMessage[] = await inflateRecentImages(toAPIMessages(recentMessages))
|
|
533
|
+
let selectedSkills = allSkills
|
|
534
|
+
let jevAgentId: string | null = null
|
|
535
|
+
let jevAgentMcpOff: string[] = []
|
|
536
|
+
let omittedMessageIds: number[] = []
|
|
537
|
+
let omittedScratchpadKeys: string[] = []
|
|
538
|
+
const objectiveSource = taskContext || userMessage
|
|
539
|
+
const objective = typeof objectiveSource === "string"
|
|
540
|
+
? objectiveSource
|
|
541
|
+
: Array.isArray(objectiveSource)
|
|
542
|
+
? objectiveSource.filter((part) => part.type === "text").map((part) => (part as { text: string }).text).join("\n")
|
|
543
|
+
: String(objectiveSource)
|
|
544
|
+
const playbookRules = (await selectPlaybookRules(objective, userId)).filter((rule) => {
|
|
545
|
+
if (!rule.applicable_to || !rule.applicable_to.includes("agent:")) return true
|
|
546
|
+
return isCatalogAgent ? rule.applicable_to.includes(`agent:${agent.id}`) : false
|
|
547
|
+
})
|
|
548
|
+
// Without a key Jev does not exist: no swarm map, no request, the classic path below.
|
|
549
|
+
const jevEnabled = !opts.skipJev && !!await getJevKey(opts.jev).catch(() => null)
|
|
550
|
+
const swarm = jevEnabled
|
|
551
|
+
? await describeSwarmCapabilities(effectiveMcpManager, { includeSpecialists: !isWorker })
|
|
552
|
+
.catch((err) => { log.warn(`[context-compiler] Swarm capability map failed: ${(err as Error).message}`); return undefined })
|
|
553
|
+
: undefined
|
|
554
|
+
const jevPlan = jevEnabled ? await planJevContext({
|
|
555
|
+
objective, messages, tools: toolsForLLM, allTools, skills: allSkills, scratchpadNotes, playbookRules, isWorker, swarm, jev: opts.jev,
|
|
556
|
+
}).catch((err) => { log.warn(`[context-compiler] Jev planning failed: ${(err as Error).message}`); return null }) : null
|
|
557
|
+
// Characters the classic path would have sent minus what Jev's plan sends,
|
|
558
|
+
// accumulated section by section; reported as estimated savings.
|
|
559
|
+
let jevSavedChars = 0
|
|
560
|
+
const classicToolCount = toolsForLLM.length
|
|
561
|
+
if (jevPlan) {
|
|
562
|
+
jevSavedChars += JSON.stringify(messages).length - JSON.stringify(jevPlan.messages).length
|
|
563
|
+
jevSavedChars += JSON.stringify(toolsForLLM).length
|
|
564
|
+
messages = jevPlan.messages
|
|
565
|
+
toolsForLLM = jevPlan.tools
|
|
566
|
+
selectedSkills = allSkills.filter(s => minimalSkills.some(m => m.id === s.id) || jevPlan.selectedSkillNames.includes(s.name))
|
|
567
|
+
jevAgentId = jevPlan.agentId
|
|
568
|
+
jevAgentMcpOff = jevPlan.agentMcpOff
|
|
569
|
+
omittedMessageIds = recentMessages.filter((_, i) => !jevPlan.selectedMessageIds.includes(i)).map(row => row.id)
|
|
570
|
+
omittedScratchpadKeys = scratchpadNotes.filter(note => !jevPlan.selectedScratchpadKeys.includes(note.key)).map(note => note.key)
|
|
571
|
+
if (!isWorker && (omittedMessageIds.length > 0 || omittedScratchpadKeys.length > 0) && !toolsForLLM.some(t => t.function.name === "conversation_read")) {
|
|
572
|
+
const reader = allTools.find(t => t.name === "conversation_read")
|
|
573
|
+
if (reader) toolsForLLM.push({ type: "function", function: { name: reader.name, description: reader.description, parameters: reader.parameters } })
|
|
574
|
+
}
|
|
575
|
+
jevSavedChars -= JSON.stringify(toolsForLLM).length
|
|
576
|
+
log.info(`[context-compiler] Jev selected messages=${jevPlan.selectedMessageIds.join(",")} tools=${toolsForLLM.map(t => t.function.name).join(",")} skills=${selectedSkills.map(s => s.name).join(",")} agent=${jevAgentId ?? "coordinator"}`)
|
|
577
|
+
}
|
|
525
578
|
|
|
526
579
|
// [STEP-10] STRATEGY 4: ISOLATE — Build context based on agent role
|
|
527
580
|
log.info(`[context-compiler] [STEP-10] Building system prompt...`)
|
|
@@ -550,33 +603,54 @@ export async function compileContext(opts: {
|
|
|
550
603
|
// budget, so a summary appended last would be the first thing silently
|
|
551
604
|
// dropped on an oversized prompt.
|
|
552
605
|
systemPrompt += conversationSummarySection
|
|
606
|
+
if (omittedMessageIds.length > 0 || omittedScratchpadKeys.length > 0) {
|
|
607
|
+
const recoverable = `\n\n# CONTEXTO RECUPERABLE\nMensajes previos omitidos: ${omittedMessageIds.join(", ") || "ninguno"}. Notas omitidas: ${omittedScratchpadKeys.join(", ") || "ninguna"}. Si necesitas un dato de ellos, usa conversation_read con message_ids, note_keys o query antes de asumir que falta información.\n`
|
|
608
|
+
systemPrompt += recoverable
|
|
609
|
+
jevSavedChars -= recoverable.length
|
|
610
|
+
}
|
|
553
611
|
|
|
554
612
|
// Only the live roster goes here — how to delegate, fan-out/fan-in and the
|
|
555
613
|
// execution-truth rules are static doctrine and live in the coordinator's
|
|
556
614
|
// stored prompt (storage/onboarding.ts), not duplicated per turn.
|
|
557
615
|
if (!isWorker) {
|
|
558
|
-
const
|
|
559
|
-
|
|
616
|
+
const catalogAgents = await listCatalogAgents()
|
|
617
|
+
const fullCatalog = `\n\n# COLMENA DE AGENTES\nWorkers disponibles ahora mismo (globales del sistema, ya existen):\n\n${renderAgentRoutingCatalog(catalogAgents)}\n`
|
|
618
|
+
if (jevPlan) {
|
|
619
|
+
// Ids, names and MCP state only: without them the coordinator spent
|
|
620
|
+
// iterations on agent_find just to learn who exists and what is
|
|
621
|
+
// connected. Descriptions stay behind agent_find.
|
|
622
|
+
const roster = swarm?.specialists.length
|
|
623
|
+
? swarm.specialists.map(renderSpecialistLine).join("\n")
|
|
624
|
+
: catalogAgents.map(a => `- ${a.id} (${a.name})`).join("\n")
|
|
625
|
+
let rosterSection = roster ? `\n\n# COLMENA DE AGENTES\nWorkers disponibles (usa agent_find solo si necesitas su descripción). Un MCP "apagado" no está disponible; "disponible" se conecta al primer uso:\n${roster}\n` : ""
|
|
626
|
+
if (jevAgentId && jevAgentMcpOff.length > 0) {
|
|
627
|
+
// Turning a server on starts processes and uses credentials: that is
|
|
628
|
+
// the user's call, so the coordinator asks instead of delegating.
|
|
629
|
+
const settingsPath = (opts.jev && opts.jev.mcpSettingsPath) || DEFAULT_JEV_MCP_SETTINGS_PATH
|
|
630
|
+
const one = jevAgentMcpOff.length === 1
|
|
631
|
+
rosterSection += `\n\n# ESPECIALISTA RECOMENDADO — MCP APAGADO\nJev seleccionó ${jevAgentId} para esta tarea, pero depende de ${one ? "el servidor MCP" : "los servidores MCP"} ${jevAgentMcpOff.join(", ")}, que está${one ? "" : "n"} apagado${one ? "" : "s"}. No lo delegues todavía: dile al usuario que para hacerlo hace falta encender ${jevAgentMcpOff.join(", ")} en ${settingsPath} y que continúas en cuanto quede conectado. Si una parte se puede resolver sin ese MCP, ofrécela.\n`
|
|
632
|
+
} else if (jevAgentId) {
|
|
633
|
+
rosterSection += `\n\n# ESPECIALISTA RECOMENDADO\nJev seleccionó ${jevAgentId} para una subtarea acotada. Delega con task_delegate cuando puedas formular objetivo y criterios verificables.\n`
|
|
634
|
+
}
|
|
635
|
+
systemPrompt += rosterSection
|
|
636
|
+
jevSavedChars += fullCatalog.length - rosterSection.length
|
|
637
|
+
} else {
|
|
638
|
+
systemPrompt += fullCatalog
|
|
639
|
+
}
|
|
560
640
|
}
|
|
561
641
|
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
? playbookInput.filter((part) => part.type === "text").map((part) => (part as any).text).join("\n")
|
|
567
|
-
: String(playbookInput)
|
|
568
|
-
const playbookRules = (await selectPlaybookRules(playbookText, userId)).filter((rule) => {
|
|
569
|
-
if (!rule.applicable_to || !rule.applicable_to.includes("agent:")) return true
|
|
570
|
-
return isCatalogAgent ? rule.applicable_to.includes(`agent:${agent.id}`) : false
|
|
571
|
-
})
|
|
572
|
-
if (playbookRules.length > 0) {
|
|
573
|
-
systemPrompt += `\n\n# PLAYBOOK APRENDIDO\n${playbookRules.map((rule) => `- ${rule.rule}`).join("\n")}\n`
|
|
642
|
+
const selectedPlaybookRules = jevPlan ? playbookRules.filter(rule => jevPlan.selectedPlaybookIds.includes(rule.id)) : playbookRules
|
|
643
|
+
for (const rule of playbookRules) if (!selectedPlaybookRules.includes(rule)) jevSavedChars += rule.rule.length + 3
|
|
644
|
+
if (selectedPlaybookRules.length > 0) {
|
|
645
|
+
systemPrompt += `\n\n# PLAYBOOK APRENDIDO\n${selectedPlaybookRules.map((rule) => `- ${rule.rule}`).join("\n")}\n`
|
|
574
646
|
}
|
|
575
647
|
|
|
576
648
|
// Inject scratchpad (Strategy: WRITE) — usando TOON para ahorro de tokens
|
|
577
|
-
|
|
649
|
+
const selectedScratchpadNotes = jevPlan ? scratchpadNotes.filter(note => jevPlan.selectedScratchpadKeys.includes(note.key)) : scratchpadNotes
|
|
650
|
+
for (const note of scratchpadNotes) if (!selectedScratchpadNotes.includes(note)) jevSavedChars += note.key.length + note.value.length + 4
|
|
651
|
+
if (selectedScratchpadNotes.length > 0) {
|
|
578
652
|
const scratchpadData: Record<string, string> = {}
|
|
579
|
-
for (const n of
|
|
653
|
+
for (const n of selectedScratchpadNotes) {
|
|
580
654
|
scratchpadData[n.key] = n.value
|
|
581
655
|
}
|
|
582
656
|
// TOON comprime el formato clave-valor
|
|
@@ -636,10 +710,10 @@ export async function compileContext(opts: {
|
|
|
636
710
|
|
|
637
711
|
|
|
638
712
|
// Inject available skills (minimal + discovered)
|
|
639
|
-
if (
|
|
713
|
+
if (selectedSkills.length > 0) {
|
|
640
714
|
// Minimal skills: inject full body (always-loaded instructions)
|
|
641
715
|
const minimalNames = new Set(minimalSkills.map(s => s.name))
|
|
642
|
-
const minimalWithBody =
|
|
716
|
+
const minimalWithBody = selectedSkills.filter(s => minimalNames.has(s.name) && s.body)
|
|
643
717
|
if (minimalWithBody.length > 0) {
|
|
644
718
|
let minimalSection = `\n\n# SKILLS SIEMPRE ACTIVAS\n`
|
|
645
719
|
for (const skill of minimalWithBody) {
|
|
@@ -649,12 +723,23 @@ export async function compileContext(opts: {
|
|
|
649
723
|
}
|
|
650
724
|
|
|
651
725
|
// Discovered skills: list only (body arrives via agent-loop when tools are injected)
|
|
652
|
-
const discoveredOnly =
|
|
726
|
+
const discoveredOnly = selectedSkills.filter(s => !minimalNames.has(s.name))
|
|
727
|
+
if (jevPlan) {
|
|
728
|
+
// Classic lists every discovered skill in one line; Jev inlines the chosen bodies.
|
|
729
|
+
for (const skill of allSkills) {
|
|
730
|
+
if (minimalNames.has(skill.name)) continue
|
|
731
|
+
jevSavedChars += `- **${skill.name}**${skill.description ? ` — ${skill.description}` : ""}\n`.length
|
|
732
|
+
if (discoveredOnly.includes(skill)) jevSavedChars -= skill.body ? `\n## ${skill.name}\n${skill.body}\n`.length : 0
|
|
733
|
+
}
|
|
734
|
+
}
|
|
653
735
|
if (discoveredOnly.length > 0) {
|
|
654
736
|
let discoveredSection = `\n\n# SKILLS DESCUBIERTAS (relevantes para esta tarea)\n`
|
|
655
737
|
for (const skill of discoveredOnly) {
|
|
656
|
-
|
|
657
|
-
|
|
738
|
+
if (jevPlan && skill.body) discoveredSection += `\n## ${skill.name}\n${skill.body}\n`
|
|
739
|
+
else {
|
|
740
|
+
const desc = skill.description ? ` — ${skill.description}` : ""
|
|
741
|
+
discoveredSection += `- **${skill.name}**${desc}\n`
|
|
742
|
+
}
|
|
658
743
|
}
|
|
659
744
|
systemPrompt += discoveredSection
|
|
660
745
|
}
|
|
@@ -702,13 +787,27 @@ export async function compileContext(opts: {
|
|
|
702
787
|
`total=${estimatedTotal}/${modelContextWindow} (${budgetPct}%)`
|
|
703
788
|
)
|
|
704
789
|
|
|
790
|
+
const jevDecision = jevPlan ? {
|
|
791
|
+
summary: [
|
|
792
|
+
`${jevPlan.selectedMessageIds.length}/${recentMessages.length} mensajes`,
|
|
793
|
+
`${toolsForLLM.length}/${classicToolCount} herramientas`,
|
|
794
|
+
`${selectedSkills.length}/${allSkills.length} skills`,
|
|
795
|
+
].join(" · "),
|
|
796
|
+
savedTokens: Math.round(jevSavedChars / 4),
|
|
797
|
+
latencyMs: jevPlan.decision.latencyMs,
|
|
798
|
+
costUsd: jevPlan.decision.costUsd,
|
|
799
|
+
recommendedAgentId: jevAgentId,
|
|
800
|
+
mcpOff: jevAgentMcpOff,
|
|
801
|
+
} : undefined
|
|
802
|
+
|
|
705
803
|
return {
|
|
706
804
|
systemPrompt,
|
|
707
805
|
conversationSummarySection,
|
|
708
806
|
messages,
|
|
709
807
|
tools: toolsForLLM,
|
|
710
808
|
allTools,
|
|
711
|
-
skills:
|
|
809
|
+
skills: selectedSkills,
|
|
810
|
+
jevDecision,
|
|
712
811
|
}
|
|
713
812
|
}
|
|
714
813
|
|
|
@@ -6,6 +6,8 @@ export * from "./catalog-selector.ts";
|
|
|
6
6
|
export * from "./context-compiler.ts";
|
|
7
7
|
export * from "./conversation-store.ts";
|
|
8
8
|
export * from "./delegation-runtime.ts";
|
|
9
|
+
export * from "./jev-decisions.ts";
|
|
10
|
+
export * from "./jev-planner.ts";
|
|
9
11
|
export * from "./llm-client.ts";
|
|
10
12
|
export * from "./minimal-loadout.ts";
|
|
11
13
|
export * from "./playbook-selector.ts";
|
|
@@ -0,0 +1,197 @@
|
|
|
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 { envSecret, 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
|
+
* never from `OPENROUTER_API_KEY`, which is the platform's.
|
|
76
|
+
*/
|
|
77
|
+
export async function getJevKey(option?: JevOption): Promise<string | null> {
|
|
78
|
+
if (option === false) return null
|
|
79
|
+
if (option) return option.apiKey || null
|
|
80
|
+
const provider = await (await col<ProviderDoc>("providers")).get("openrouter")
|
|
81
|
+
if (!provider?.doc.enabled || !provider.doc.active) return null
|
|
82
|
+
return (await loadProviderApiKey("openrouter")) || envSecret("OPENROUTER_API_KEY") || null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface JevStatus {
|
|
86
|
+
state: "off" | "ready" | "fallback"
|
|
87
|
+
lastError: string | null
|
|
88
|
+
lastSuccessAt: number | null
|
|
89
|
+
totals: { decisions: number; savedTokens: number; costUsd: number }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function getJevStatus(option?: JevOption): Promise<JevStatus> {
|
|
93
|
+
const key = await getJevKey(option).catch(() => null)
|
|
94
|
+
const state = tenantState()
|
|
95
|
+
return {
|
|
96
|
+
state: !key ? "off" : Date.now() < state.cooldownUntil || state.lastError ? "fallback" : "ready",
|
|
97
|
+
lastError: key ? state.lastError : null,
|
|
98
|
+
lastSuccessAt: key ? state.lastSuccessAt : null,
|
|
99
|
+
totals: { ...state.totals },
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function broadcastStatus(option?: JevOption): void {
|
|
104
|
+
getJevStatus(option).then(status => emitCanvas("canvas:jev_status", status)).catch(() => { /* best effort */ })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Publishes a served decision to the office and persists it for the dashboard.
|
|
109
|
+
* Callers estimate savings; Jev itself only answers questions. `provider`/`model`
|
|
110
|
+
* are the advised agent's main model, used to price the avoided tokens.
|
|
111
|
+
* Returns the published event so the caller can forward it to its own host.
|
|
112
|
+
*/
|
|
113
|
+
export function emitJevDecision({ provider, model, ...decision }: Omit<CanvasJevDecision, "eventId" | "totals"> & { provider: string; model: string }): CanvasJevDecision {
|
|
114
|
+
recordJevDecision({ agentId: decision.agentId, provider, model, savedTokens: decision.savedTokens, costUsd: decision.costUsd })
|
|
115
|
+
const { totals } = tenantState()
|
|
116
|
+
totals.decisions++
|
|
117
|
+
totals.savedTokens += decision.savedTokens
|
|
118
|
+
totals.costUsd += decision.costUsd
|
|
119
|
+
const event = {
|
|
120
|
+
...decision,
|
|
121
|
+
eventId: `jev:${Date.now().toString(36)}:${++decisionSequence}`,
|
|
122
|
+
summary: decision.summary.slice(0, 160),
|
|
123
|
+
totals: { ...totals },
|
|
124
|
+
} satisfies CanvasJevDecision
|
|
125
|
+
emitCanvas("canvas:jev_decision", event)
|
|
126
|
+
return event
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Clears the current tenant's failure state (after its key changed, for instance). */
|
|
130
|
+
export function resetJevStatus(option?: JevOption): void {
|
|
131
|
+
const state = tenantState()
|
|
132
|
+
state.failures = 0
|
|
133
|
+
state.cooldownUntil = 0
|
|
134
|
+
state.lastError = null
|
|
135
|
+
state.lastSuccessAt = null
|
|
136
|
+
broadcastStatus(option)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function askJev(
|
|
140
|
+
state: unknown,
|
|
141
|
+
questions: Record<string, JevQuestion>,
|
|
142
|
+
options: { fetcher?: typeof fetch; signal?: AbortSignal; jev?: JevOption } = {},
|
|
143
|
+
): Promise<JevResult | null> {
|
|
144
|
+
const key = await getJevKey(options.jev).catch(() => null)
|
|
145
|
+
const tenant = tenantState()
|
|
146
|
+
if (!key || Date.now() < tenant.cooldownUntil || Object.keys(questions).length === 0) return null
|
|
147
|
+
const started = performance.now()
|
|
148
|
+
try {
|
|
149
|
+
const response = await (options.fetcher ?? fetch)(ENDPOINT, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
152
|
+
body: JSON.stringify({ model: JEV_MODEL, state, questions }),
|
|
153
|
+
signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(TIMEOUT_MS)]) : AbortSignal.timeout(TIMEOUT_MS),
|
|
154
|
+
})
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
if (response.status === 401 || response.status === 403) tenant.cooldownUntil = Date.now() + COOLDOWN_MS
|
|
157
|
+
throw new Error(`OpenRouter HTTP ${response.status}`)
|
|
158
|
+
}
|
|
159
|
+
const data = await response.json() as {
|
|
160
|
+
answers?: Record<string, JevAnswer>
|
|
161
|
+
usage?: { input_tokens?: number; output_tokens?: number; cost?: number }
|
|
162
|
+
}
|
|
163
|
+
const answers: Record<string, JevAnswer> = {}
|
|
164
|
+
for (const [name, question] of Object.entries(questions)) {
|
|
165
|
+
const answer = data.answers?.[name]
|
|
166
|
+
if (question.type === "choice") {
|
|
167
|
+
if (answer?.type !== "choice" || !Object.hasOwn(question.criteria, answer.choice) ||
|
|
168
|
+
!Number.isFinite(answer.confidence) || answer.confidence < 0 || answer.confidence > 1) {
|
|
169
|
+
throw new Error(`Invalid choice answer: ${name}`)
|
|
170
|
+
}
|
|
171
|
+
} else if (answer?.type !== "noul" || !Number.isFinite(answer.noul) || answer.noul < 0 || answer.noul > 1) {
|
|
172
|
+
throw new Error(`Invalid noul answer: ${name}`)
|
|
173
|
+
}
|
|
174
|
+
answers[name] = answer
|
|
175
|
+
}
|
|
176
|
+
const recovered = tenant.lastError !== null
|
|
177
|
+
tenant.failures = 0
|
|
178
|
+
tenant.lastError = null
|
|
179
|
+
tenant.lastSuccessAt = Date.now()
|
|
180
|
+
if (recovered) broadcastStatus(options.jev)
|
|
181
|
+
const inputTokens = data.usage?.input_tokens ?? 0
|
|
182
|
+
const costUsd = data.usage?.cost ?? inputTokens * 0.042 / 1_000_000
|
|
183
|
+
log.info(`Decision served: questions=${Object.keys(questions).join(",")} latency_ms=${Math.round(performance.now() - started)} input_tokens=${inputTokens} cost_usd=${costUsd}`)
|
|
184
|
+
if (inputTokens > 0) {
|
|
185
|
+
recordUsage({ provider: "openrouter", model: catalogModelKey("openrouter", JEV_MODEL), inputTokens, outputTokens: data.usage?.output_tokens ?? 0, latencyMs: Math.round(performance.now() - started) })
|
|
186
|
+
}
|
|
187
|
+
return { answers, inputTokens, costUsd, latencyMs: Math.round(performance.now() - started) }
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (options.signal?.aborted) return null
|
|
190
|
+
tenant.failures++
|
|
191
|
+
tenant.lastError = error instanceof Error ? error.message : "Jev unavailable"
|
|
192
|
+
if (tenant.failures >= 3) tenant.cooldownUntil = Date.now() + COOLDOWN_MS
|
|
193
|
+
log.warn(`Decision fallback: ${tenant.lastError}`)
|
|
194
|
+
broadcastStatus(options.jev)
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
}
|