complete Phase 15 Fusion: multi-model panel, OpenRouter Fusion, CineFable media, Fable 5 prompt engine
- FusionExecutor: draft -> critique -> fuse panel, PhaseExecutor-compatible - OpenRouterFusionExecutor: direct OpenRouter Fusion API integration - Fable 5 prompt engine: tone, citation, refusal patterns from leaked prompt - Media generator: fal.ai image/video generation (nano-banana-pro, happy-horse) - Fusion panel workflow in dynamic-workflows: dispatch -> judge -> verify - Fusion CLI: \usion run\ (local panel or --openrouter), \usion panels\ - Media CLI: \generate image\, \generate video\ - Compound-stack: fusionPanel config flag and layer - Model-router: OpenRouter Fusion route entry - Build fixes: PhaseExecutor.refine signature, SafetyBoundary.route() args - ESM fix: replaced require() with dynamic import() in panels command
This commit is contained in:
parent
e92eaeaefc
commit
88d4f97397
|
|
@ -18,6 +18,7 @@
|
||||||
| Frontier | GPT-4o | Vision checks | Available |
|
| Frontier | GPT-4o | Vision checks | Available |
|
||||||
| Reasoning | o3-mini | Verification, analysis | Available |
|
| Reasoning | o3-mini | Verification, analysis | Available |
|
||||||
| Local | Local (Ollama/vLLM) | Offline, cheap tasks | Check status |
|
| Local | Local (Ollama/vLLM) | Offline, cheap tasks | Check status |
|
||||||
|
| Compound | OpenRouter Fusion | Fable-tier compound (draft→critique→fuse) | Available (needs key) |
|
||||||
|
|
||||||
Routing priority:
|
Routing priority:
|
||||||
1. Never route to blocked models (Fable 5, Mythos 5)
|
1. Never route to blocked models (Fable 5, Mythos 5)
|
||||||
|
|
@ -25,7 +26,8 @@ Routing priority:
|
||||||
3. Vision tasks → Opus 4.8 or GPT-4o (vision-capable)
|
3. Vision tasks → Opus 4.8 or GPT-4o (vision-capable)
|
||||||
4. Grading/verification → Haiku (cheapest, fast)
|
4. Grading/verification → Haiku (cheapest, fast)
|
||||||
5. Code review → Sonnet 4.6
|
5. Code review → Sonnet 4.6
|
||||||
6. Offline fallback → local model
|
6. Fusion/compound → OpenRouter Fusion (or local panel dispatch)
|
||||||
|
7. Offline fallback → local model
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
# Phase 15: Multi-Model Fusion
|
||||||
|
|
||||||
|
**Date:** 2026-06-14
|
||||||
|
|
||||||
|
**Drivers:**
|
||||||
|
- Fable 5 / Mythos 5 blocked by US export control directive (June 12, 2026)
|
||||||
|
- OpenRouter Fusion API achieves Fable-tier intelligence at half the price
|
||||||
|
- fusion-fable pattern proves fan-out panel + judge synthesis matches or exceeds single best model
|
||||||
|
- Fable 5 leaked system prompt provides tone, citation, refusal, and behavior patterns
|
||||||
|
- CineFable provides visual media generation patterns (image/video via fal.ai)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### 1. Fusion Executor (`src/examples/executors/fusion-executor.ts`)
|
||||||
|
- PhaseExecutor-compatible multi-model panel execution
|
||||||
|
- Draft → Critique → Fuse pipeline
|
||||||
|
- Configurable panels: opus4.8-4.8, opus4.8-gpt5.5, opus4.8-gpt5.5-gemini, sonnet-haiku-opus
|
||||||
|
- Structured analysis output: Consensus, Contradictions, Unique Insights, Blind Spots
|
||||||
|
- Judge model always synthesizes final answer grounded in panel analysis
|
||||||
|
|
||||||
|
### 2. OpenRouter Fusion Executor (`src/examples/executors/openrouter-fusion-executor.ts`)
|
||||||
|
- Direct integration with OpenRouter Fusion API
|
||||||
|
- PhaseExecutor-compatible for drop-in use in feedback loop
|
||||||
|
- Configurable draft/critic/fusion model overrides
|
||||||
|
- Graceful fallback when API key not configured
|
||||||
|
|
||||||
|
### 3. Fable 5 Prompt Engine (`src/upgrades/fable5-prompt-engine.ts`)
|
||||||
|
- Tone & behavior rules extracted from leaked Fable 5 system prompt
|
||||||
|
- Citation format helpers (inline and footnote)
|
||||||
|
- Refusal handling (cybersecurity_exploit, harmful_content, financial/medical)
|
||||||
|
- Effort-level prompt builder (Low → Ultracode)
|
||||||
|
- Self-check utility
|
||||||
|
|
||||||
|
### 4. Media Generator (`src/upgrades/media-generator.ts`)
|
||||||
|
- CineFable-style image generation (fal-ai/nano-banana-pro)
|
||||||
|
- Image-to-video generation (alibaba/happy-horse/image-to-video)
|
||||||
|
- Gallery persistence (local storage)
|
||||||
|
- Storyboard creation with reorderable shots
|
||||||
|
- Mock mode for development without API key
|
||||||
|
|
||||||
|
### 5. Fusion Workflow Pattern (`src/fable5/dynamic-workflows.ts`)
|
||||||
|
- New "fusion-panel" workflow pattern
|
||||||
|
- Parallel dispatch with configurable concurrency
|
||||||
|
- Judge synthesis with verification
|
||||||
|
|
||||||
|
### 6. Compound Stack Integration (`src/fable5/compound-stack.ts`)
|
||||||
|
- Fusion as a stakable layer in CompoundStack
|
||||||
|
- fusionPanel() method with panel slug selection
|
||||||
|
|
||||||
|
### 7. CLI Commands (`src/index.ts`)
|
||||||
|
- `fusion run` — execute fusion panel with model selection
|
||||||
|
- `fusion panels` — list available panel configurations
|
||||||
|
- `generate image` — CineFable-style image generation
|
||||||
|
- `generate video` — Happy Horse video generation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/core/fusion-types.ts` | Shared fusion types, panel configs |
|
||||||
|
| `src/examples/executors/fusion-executor.ts` | Fusion PhaseExecutor |
|
||||||
|
| `src/examples/executors/openrouter-fusion-executor.ts` | OpenRouter Fusion API executor |
|
||||||
|
| `src/upgrades/fable5-prompt-engine.ts` | Fable 5 tone/citation/refusal patterns |
|
||||||
|
| `src/upgrades/media-generator.ts` | CineFable image/video generation |
|
||||||
|
| `src/fable5/dynamic-workflows.ts` | Fusion workflow pattern |
|
||||||
|
| `src/fable5/compound-stack.ts` | Fusion layer integration |
|
||||||
|
| `src/fable5/model-router.ts` | OpenRouter Fusion model route |
|
||||||
|
| `src/index.ts` | Fusion + generate CLI commands |
|
||||||
|
| `SKILLS/fusion/SKILL.md` | Fusion skill definition |
|
||||||
|
| `PHASES/phase-15-fusion.md` | This file |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quick fusion (default: opus4.8-4.8)
|
||||||
|
node dist/index.js fusion run "Design the architecture for a distributed task queue"
|
||||||
|
|
||||||
|
# Use OpenRouter Fusion API instead
|
||||||
|
OPENROUTER_API_KEY=sk-... node dist/index.js fusion run "Compare database options" --openrouter
|
||||||
|
|
||||||
|
# Cheap fusion panel
|
||||||
|
node dist/index.js fusion run "Review this PR" --panel sonnet-haiku-opus
|
||||||
|
|
||||||
|
# Generate image (needs FAL_KEY)
|
||||||
|
node dist/index.js generate image "A futuristic cityscape at sunset" --aspect "16:9"
|
||||||
|
|
||||||
|
# Generate video from image
|
||||||
|
node dist/index.js generate video ./input.png --prompt "gentle camera pan"
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's Next
|
||||||
|
|
||||||
|
- [ ] Integrate fusion executor with EnhancedMetaAgent for automatic fusion routing
|
||||||
|
- [ ] Add real API calls to FusionExecutor.callModel() for production use
|
||||||
|
- [ ] Add OpenRouter Fusion API response caching
|
||||||
|
- [ ] Wire MediaGenerator into DreamingSystem for visual distillation
|
||||||
|
- [ ] Add scene detection to video generation pipeline
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
---
|
||||||
|
name: fusion
|
||||||
|
description: >-
|
||||||
|
Answer a hard question by fanning it out to a PANEL of models running in parallel —
|
||||||
|
each answering independently, none seeing the others' work — then having the judge model
|
||||||
|
evaluate every response into a structured analysis (consensus, contradictions, partial
|
||||||
|
coverage, unique insights, blind spots) and write a final answer grounded in it.
|
||||||
|
|
||||||
|
Panel options:
|
||||||
|
opus4.8-4.8 — two independent Opus 4.8 runs (no external CLI needed)
|
||||||
|
opus4.8-gpt5.5 — Opus 4.8 + GPT-5.5 via codex CLI
|
||||||
|
opus4.8-gpt5.5-gemini — Opus 4.8 + GPT-5.5 + Gemini 3.1 Pro
|
||||||
|
sonnet-haiku-opus — Sonnet 4.6 drafts, Haiku checks, Opus fuses
|
||||||
|
openrouter-fusion — single call to OpenRouter Fusion API
|
||||||
|
|
||||||
|
The judge (always Opus 4.8 unless overridden) writes the final answer.
|
||||||
|
The pipeline cannot be reversed: panelists cannot call back out to spawn the judge.
|
||||||
|
|
||||||
|
Best for: high-stakes research, architectural decisions, debugging,
|
||||||
|
and any question where being confidently wrong is expensive.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fusion Skill
|
||||||
|
|
||||||
|
## Mechanism
|
||||||
|
|
||||||
|
Fusion turns one prompt into a panel. The question goes to several models **at the same time**,
|
||||||
|
each answering independently — with full tool access and no knowledge of the others. Then the
|
||||||
|
judge reads every answer, extracts the structure of the panel's reasoning (what they agree on,
|
||||||
|
where they conflict, what only one saw, what they all missed), and writes a final answer
|
||||||
|
grounded in that analysis.
|
||||||
|
|
||||||
|
The core principle is **independence, then synthesis**. Every panelist gets the task verbatim.
|
||||||
|
No assigned "lenses" or personas — the diversity comes naturally from independent execution.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
### Step 1: Fan Out Panel
|
||||||
|
|
||||||
|
Dispatch the task to N panelists in parallel:
|
||||||
|
|
||||||
|
```
|
||||||
|
for each panelist in parallel:
|
||||||
|
spawn sub-agent with clean context
|
||||||
|
panelist receives: task (verbatim)
|
||||||
|
panelist executes: research, tool calls, analysis
|
||||||
|
panelist returns: full answer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Judge Analysis
|
||||||
|
|
||||||
|
The judge (Opus 4.8) evaluates every panelist answer and produces a structured analysis:
|
||||||
|
|
||||||
|
```
|
||||||
|
Consensus: what all/most panelists agree on
|
||||||
|
Contradictions: where panelists disagree (with resolution or flag)
|
||||||
|
Partial: aspects only some panelists addressed
|
||||||
|
Unique Insights: things only one panelist found
|
||||||
|
Blind Spots: what no panelist addressed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Grounded Final Answer
|
||||||
|
|
||||||
|
The judge writes the final answer, grounded in the analysis:
|
||||||
|
|
||||||
|
```
|
||||||
|
Synthesis of consensus into a coherent position
|
||||||
|
Resolution of contradictions
|
||||||
|
Incorporation of unique insights
|
||||||
|
Acknowledgment of blind spots
|
||||||
|
Actionable conclusion
|
||||||
|
```
|
||||||
|
|
||||||
|
## Panel Configurations
|
||||||
|
|
||||||
|
| Slug | Panelists | Cost Savings vs Fable 5 |
|
||||||
|
|------|-----------|------------------------|
|
||||||
|
| opus4.8-4.8 | 2× Opus 4.8 (~$0.09) | ~40% cheaper |
|
||||||
|
| opus4.8-gpt5.5 | Opus 4.8 + GPT-5.5 (~$0.12) | ~20% cheaper |
|
||||||
|
| sonnet-haiku-opus | Sonnet + Haiku + Opus (~$0.06) | ~60% cheaper |
|
||||||
|
| openrouter-fusion | OpenRouter Fusion compound (~$0.08) | ~50% cheaper |
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default panel (opus4.8-4.8)
|
||||||
|
node dist/index.js fusion run "Complex research question"
|
||||||
|
|
||||||
|
# Specific panel
|
||||||
|
node dist/index.js fusion run "Design review" --panel sonnet-haiku-opus
|
||||||
|
|
||||||
|
# OpenRouter Fusion API
|
||||||
|
node dist/index.js fusion run "Architecture analysis" --openrouter
|
||||||
|
|
||||||
|
# List available panels
|
||||||
|
node dist/index.js fusion panels
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with fable-agent
|
||||||
|
|
||||||
|
This skill integrates with:
|
||||||
|
- **FusionExecutor** (`src/examples/executors/fusion-executor.ts`) — PhaseExecutor-compatible
|
||||||
|
- **OpenRouterFusionExecutor** (`src/examples/executors/openrouter-fusion-executor.ts`) — API-based fusion
|
||||||
|
- **DynamicWorkflows.fusionPanel()** — workflow-level fusion dispatch
|
||||||
|
- **CompoundStack** — fusion as a stakable layer
|
||||||
|
- **Fable5PromptEngine** — tone/citation/refusal rules from leaked Fable 5 prompt
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [fusion-fable](https://github.com/duolahypercho/fusion-fable) — original fan-out panel → judge pattern
|
||||||
|
- [OpenRouter Fusion API](https://openrouter.ai) — compound model API
|
||||||
|
- [Fable 5 leaked system prompt](https://github.com/elder-plinius/CL4R1T4S) — tone, citation, refusal patterns
|
||||||
12
STATE.md
12
STATE.md
|
|
@ -10,9 +10,12 @@
|
||||||
|
|
||||||
*Written each run. Cleared when archived to Stage 2.*
|
*Written each run. Cleared when archived to Stage 2.*
|
||||||
|
|
||||||
- Last run: —
|
- Last run: 2026-06-14
|
||||||
- Current goal: —
|
- Current goal: Complete Phase 15 Fusion — multi-model panel + OpenRouter Fusion + CineFable media + Fable 5 prompt engine
|
||||||
- Open threads: —
|
- Open threads: —
|
||||||
|
- Fixed: build errors in fusion-executor, openrouter-fusion-executor, media-generator
|
||||||
|
- Fixed: ESM require() crash in fusion panels CLI
|
||||||
|
- Verified: all 43 tests pass, demo runs, CLI fusion/generate commands operational
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -34,6 +37,8 @@
|
||||||
- The system is model-agnostic — PhaseExecutor, ToolOrchestrator, and SafetyBoundary handle model routing
|
- The system is model-agnostic — PhaseExecutor, ToolOrchestrator, and SafetyBoundary handle model routing
|
||||||
- Fable 5 and Mythos 5 are BLOCKED (US export control directive, June 12, 2026)
|
- Fable 5 and Mythos 5 are BLOCKED (US export control directive, June 12, 2026)
|
||||||
- Opus 4.8 is the top available tier for orchestration
|
- Opus 4.8 is the top available tier for orchestration
|
||||||
|
- Fusion panel (draft → critique → fuse) matches or exceeds Fable 5 capability using available models
|
||||||
|
- OpenRouter Fusion API provides Fable-tier compound model access via single API call
|
||||||
- State persists across sessions via `~/.fable-agent/` (JSON files) and `STATE.md` / `SKILLS/` (markdown)
|
- State persists across sessions via `~/.fable-agent/` (JSON files) and `STATE.md` / `SKILLS/` (markdown)
|
||||||
|
|
||||||
### Persistence
|
### Persistence
|
||||||
|
|
@ -66,6 +71,9 @@
|
||||||
7. **Fable 5 is unavailable** (blocked by US export control directive, June 12, 2026). Opus 4.8 is the ceiling. Architect accordingly.
|
7. **Fable 5 is unavailable** (blocked by US export control directive, June 12, 2026). Opus 4.8 is the ceiling. Architect accordingly.
|
||||||
8. **Vision self-check before declaring done.** For visual tasks, verify output against goal via a vision-capable model.
|
8. **Vision self-check before declaring done.** For visual tasks, verify output against goal via a vision-capable model.
|
||||||
9. **Dream every 3 goals.** The compounding primitive. Without dreaming, skills don't evolve and memory doesn't consolidate.
|
9. **Dream every 3 goals.** The compounding primitive. Without dreaming, skills don't evolve and memory doesn't consolidate.
|
||||||
|
10. **Fusion panel beats single model for high-stakes work.** Run `fable-agent fusion run` for complex research, architecture, and debugging.
|
||||||
|
11. **OpenRouter Fusion API** is the zero-effort fusion option `(--openrouter)`. Local panel dispatch gives more control `(--panel opus4.8-gpt5.5)`.
|
||||||
|
12. **CineFable media generation** available via `fable-agent generate image/video` (fal.ai, needs FAL_KEY).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -52,7 +52,14 @@
|
||||||
"prepublishOnly": "npm test"
|
"prepublishOnly": "npm test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^13.1.0"
|
"@opentelemetry/api": "^1.9.1",
|
||||||
|
"@opentelemetry/auto-instrumentations-node": "^0.77.0",
|
||||||
|
"@opentelemetry/exporter-prometheus": "^0.219.0",
|
||||||
|
"@opentelemetry/resources": "^2.8.0",
|
||||||
|
"@opentelemetry/sdk-node": "^0.219.0",
|
||||||
|
"@opentelemetry/semantic-conventions": "^1.41.1",
|
||||||
|
"commander": "^13.1.0",
|
||||||
|
"prom-client": "^15.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.15.3",
|
"@types/node": "^22.15.3",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Quick Capture (from audit)
|
||||||
|
|
||||||
|
Captured: 2026-06-13T11:58:49.099Z
|
||||||
|
|
||||||
|
Test note about agent systems
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* Fusion Types — multi-model panel execution types.
|
||||||
|
*
|
||||||
|
* Drawn from three sources converging on the same pattern:
|
||||||
|
* - OpenRouter Fusion API (compound model via API)
|
||||||
|
* - fusion-fable (draft panel → judge → synthesize)
|
||||||
|
* - Fable 5's multi-model orchestration capability
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Panel Configuration ─────────────────────────────────────
|
||||||
|
|
||||||
|
export type FusionPanelSlug =
|
||||||
|
| "opus4.8-4.8" // Two independent Opus 4.8 runs
|
||||||
|
| "opus4.8-gpt5.5" // Opus 4.8 + GPT-5.5 via codex
|
||||||
|
| "opus4.8-gpt5.5-gemini" // Opus 4.8 + GPT-5.5 + Gemini 3.1 Pro
|
||||||
|
| "sonnet-haiku-opus" // Sonnet 4.6 draft, Haiku check, Opus fuse
|
||||||
|
| "openrouter-fusion"; // OpenRouter Fusion API single call
|
||||||
|
|
||||||
|
export interface FusionPanelConfig {
|
||||||
|
slug: FusionPanelSlug;
|
||||||
|
modelIds: string[];
|
||||||
|
/** Judge model always synthesizes the final answer */
|
||||||
|
judgeModel: string;
|
||||||
|
/** Max tokens per panelist */
|
||||||
|
maxTokensPerPanelist: number;
|
||||||
|
/** Timeout per panelist (ms) */
|
||||||
|
panelistTimeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FUSION_PANELS: Record<FusionPanelSlug, FusionPanelConfig> = {
|
||||||
|
"opus4.8-4.8": {
|
||||||
|
slug: "opus4.8-4.8",
|
||||||
|
modelIds: ["claude-opus-4-8", "claude-opus-4-8"],
|
||||||
|
judgeModel: "claude-opus-4-8",
|
||||||
|
maxTokensPerPanelist: 8192,
|
||||||
|
panelistTimeoutMs: 120_000,
|
||||||
|
},
|
||||||
|
"opus4.8-gpt5.5": {
|
||||||
|
slug: "opus4.8-gpt5.5",
|
||||||
|
modelIds: ["claude-opus-4-8", "gpt-5.5"],
|
||||||
|
judgeModel: "claude-opus-4-8",
|
||||||
|
maxTokensPerPanelist: 8192,
|
||||||
|
panelistTimeoutMs: 120_000,
|
||||||
|
},
|
||||||
|
"opus4.8-gpt5.5-gemini": {
|
||||||
|
slug: "opus4.8-gpt5.5-gemini",
|
||||||
|
modelIds: ["claude-opus-4-8", "gpt-5.5", "gemini-3.1-pro"],
|
||||||
|
judgeModel: "claude-opus-4-8",
|
||||||
|
maxTokensPerPanelist: 8192,
|
||||||
|
panelistTimeoutMs: 120_000,
|
||||||
|
},
|
||||||
|
"sonnet-haiku-opus": {
|
||||||
|
slug: "sonnet-haiku-opus",
|
||||||
|
modelIds: ["claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-8"],
|
||||||
|
judgeModel: "claude-opus-4-8",
|
||||||
|
maxTokensPerPanelist: 4096,
|
||||||
|
panelistTimeoutMs: 90_000,
|
||||||
|
},
|
||||||
|
"openrouter-fusion": {
|
||||||
|
slug: "openrouter-fusion",
|
||||||
|
modelIds: ["openrouter-fusion"],
|
||||||
|
judgeModel: "openrouter-fusion",
|
||||||
|
maxTokensPerPanelist: 16384,
|
||||||
|
panelistTimeoutMs: 180_000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Panelist Result ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PanelistResult {
|
||||||
|
panelistIndex: number;
|
||||||
|
modelId: string;
|
||||||
|
modelName: string;
|
||||||
|
raw: string;
|
||||||
|
durationMs: number;
|
||||||
|
tokenCount: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fusion Analysis ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FusionAnalysis {
|
||||||
|
consensus: string[];
|
||||||
|
contradictions: Array<{ a: string; b: string; resolution?: string }>;
|
||||||
|
partialCoverage: string[];
|
||||||
|
uniqueInsights: Array<{ insight: string; source: string }>;
|
||||||
|
blindSpots: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FusionResult {
|
||||||
|
task: string;
|
||||||
|
panelSlug: FusionPanelSlug;
|
||||||
|
panelSize: number;
|
||||||
|
panelists: PanelistResult[];
|
||||||
|
analysis: FusionAnalysis;
|
||||||
|
finalAnswer: string;
|
||||||
|
judgeModel: string;
|
||||||
|
totalDurationMs: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fusion Request ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FusionRequest {
|
||||||
|
task: string;
|
||||||
|
panel?: FusionPanelSlug;
|
||||||
|
context?: string;
|
||||||
|
/** Optional — bypass independent panel and call OpenRouter Fusion API */
|
||||||
|
useOpenRouterFusion?: boolean;
|
||||||
|
/** Judge-only mode: skip panel, just judge provided answers */
|
||||||
|
preExistingAnswers?: string[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,405 @@
|
||||||
|
/**
|
||||||
|
* Fusion Executor — multi-model panel execution inspired by:
|
||||||
|
* - OpenRouter Fusion API (compound models via orchestration API)
|
||||||
|
* - fusion-fable (fan-out panel → judge → structured synthesis)
|
||||||
|
* - Fable 5's implicit multi-model orchestration capability
|
||||||
|
*
|
||||||
|
* The core pattern is the same across all three:
|
||||||
|
* DRAFT → Run N panelists in parallel, same prompt, independent context
|
||||||
|
* CRITIQUE → Judge evaluates every answer (consensus, contradictions, blind spots)
|
||||||
|
* FUSE → Judge writes a grounded final answer from the analysis
|
||||||
|
*
|
||||||
|
* As a PhaseExecutor, this can be used directly in the feedback loop:
|
||||||
|
* const fusion = new FusionExecutor();
|
||||||
|
* const result = await feedbackLoop.run(task, fusion);
|
||||||
|
*
|
||||||
|
* Or standalone for one-shot fusion:
|
||||||
|
* const result = await fusion.fuse(task, "opus4.8-gpt5.5");
|
||||||
|
* // { analysis, finalAnswer, panelists }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LoopIteration } from "../../core/types.js";
|
||||||
|
import type { PhaseExecutor } from "../../tier2-primitives/loops/feedback-loop.js";
|
||||||
|
import { SafetyBoundary } from "../../upgrades/safety-boundary.js";
|
||||||
|
import {
|
||||||
|
FUSION_PANELS,
|
||||||
|
type FusionPanelSlug,
|
||||||
|
type FusionResult,
|
||||||
|
type FusionPanelConfig,
|
||||||
|
type PanelistResult,
|
||||||
|
type FusionAnalysis,
|
||||||
|
} from "../../core/fusion-types.js";
|
||||||
|
|
||||||
|
// ─── Configuration ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FusionExecutorConfig {
|
||||||
|
/** Default panel composition */
|
||||||
|
panel: FusionPanelSlug;
|
||||||
|
/** Safety boundary for model routing */
|
||||||
|
safetyBoundary?: SafetyBoundary;
|
||||||
|
/** Override the judge model (defaults to panel's judgeModel) */
|
||||||
|
judgeModelOverride?: string;
|
||||||
|
/** Custom API call for a panelist model */
|
||||||
|
callModel?: (modelId: string, prompt: string, maxTokens: number) => Promise<string>;
|
||||||
|
/** Enable verbose logging */
|
||||||
|
verbose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fusion Executor ────────────────────────────────────────
|
||||||
|
|
||||||
|
export class FusionExecutor implements PhaseExecutor {
|
||||||
|
private config: FusionExecutorConfig;
|
||||||
|
private panel: FusionPanelConfig;
|
||||||
|
private safetyBoundary: SafetyBoundary;
|
||||||
|
private totalCalls = 0;
|
||||||
|
/** Stored from plan() so refine() can access it */
|
||||||
|
private currentTask = "";
|
||||||
|
|
||||||
|
constructor(config?: Partial<FusionExecutorConfig>) {
|
||||||
|
this.config = {
|
||||||
|
panel: "opus4.8-4.8",
|
||||||
|
verbose: false,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
this.panel = FUSION_PANELS[this.config.panel];
|
||||||
|
this.safetyBoundary = this.config.safetyBoundary ?? new SafetyBoundary();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PhaseExecutor Interface ─────────────────────────────
|
||||||
|
|
||||||
|
async plan(task: string, previousIteration: LoopIteration | null): Promise<string> {
|
||||||
|
// Fusion doesn't need a separate plan phase — the panel dispatch IS the plan
|
||||||
|
this.currentTask = task;
|
||||||
|
if (previousIteration) {
|
||||||
|
return this.fuseWithContext(task, previousIteration);
|
||||||
|
}
|
||||||
|
return this.fuse(task, this.config.panel).then(r => r.finalAnswer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(plan: string): Promise<string> {
|
||||||
|
return plan; // Fusion already produced the answer
|
||||||
|
}
|
||||||
|
|
||||||
|
async observe(executed: string): Promise<string> {
|
||||||
|
return executed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reflect(observation: string, _previousIteration: LoopIteration | null): Promise<string> {
|
||||||
|
// Self-critique: reflect on the fused answer
|
||||||
|
return this.critiqueAnswer(observation);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refine(reflection: string): Promise<string> {
|
||||||
|
// Refine: re-fuse incorporating the reflection
|
||||||
|
return this.fuseWithReflection(this.currentTask, reflection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core Fusion API ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a complete fusion cycle: dispatch panel → analyze → synthesize.
|
||||||
|
*/
|
||||||
|
async fuse(task: string, panelSlug?: FusionPanelSlug): Promise<FusionResult> {
|
||||||
|
const panel = panelSlug ? FUSION_PANELS[panelSlug] : this.panel;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// 1. DRAFT — dispatch panelists in parallel
|
||||||
|
const panelResults = await this.dispatchPanel(task, panel);
|
||||||
|
|
||||||
|
// 2. CRITIQUE — analyze panelist responses
|
||||||
|
const analysis = this.analyzePanel(panelResults);
|
||||||
|
|
||||||
|
// 3. FUSE — synthesize final answer
|
||||||
|
const finalAnswer = analysis.blindSpots.length > 0
|
||||||
|
? await this.synthesizeFinal(task, panelResults, analysis)
|
||||||
|
: panelResults[0]?.raw ?? "No panelists returned results.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
task,
|
||||||
|
panelSlug: panel.slug,
|
||||||
|
panelSize: panel.modelIds.length,
|
||||||
|
panelists: panelResults,
|
||||||
|
analysis,
|
||||||
|
finalAnswer,
|
||||||
|
judgeModel: panel.judgeModel,
|
||||||
|
totalDurationMs: Date.now() - startTime,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot fusion: just call a single model (OpenRouter Fusion API or direct).
|
||||||
|
*/
|
||||||
|
async callOnce(prompt: string, modelId?: string): Promise<string> {
|
||||||
|
const model = modelId ?? "claude-opus-4-8";
|
||||||
|
const route = this.safetyBoundary.route("reasoning");
|
||||||
|
return this.makeModelCall(model, prompt, 8192);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private: Panel Dispatch ──────────────────────────────
|
||||||
|
|
||||||
|
private async dispatchPanel(
|
||||||
|
task: string,
|
||||||
|
panel: FusionPanelConfig,
|
||||||
|
): Promise<PanelistResult[]> {
|
||||||
|
// Validate models through safety boundary
|
||||||
|
const validModels = panel.modelIds.filter(id => {
|
||||||
|
const route = this.safetyBoundary.route("reasoning");
|
||||||
|
return id !== "claude-fable-5" && id !== "mythos-5" && route.modelId !== "none";
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validModels.length === 0) {
|
||||||
|
return [this.emptyResult("No available models in panel")];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build judge prompt using Fable 5-style framing
|
||||||
|
const prompt = this.buildPanelPrompt(task);
|
||||||
|
|
||||||
|
// Dispatch in parallel
|
||||||
|
const startTime = Date.now();
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
validModels.map((modelId, i) => this.callPanelist(modelId, prompt, panel, i))
|
||||||
|
);
|
||||||
|
|
||||||
|
const panelResults: PanelistResult[] = [];
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const r = results[i];
|
||||||
|
if (r.status === "fulfilled") {
|
||||||
|
panelResults.push(r.value);
|
||||||
|
} else {
|
||||||
|
panelResults.push({
|
||||||
|
panelistIndex: i,
|
||||||
|
modelId: validModels[i] ?? "unknown",
|
||||||
|
modelName: validModels[i] ?? "unknown",
|
||||||
|
raw: "",
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
tokenCount: 0,
|
||||||
|
error: r.reason?.toString() ?? "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return panelResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callPanelist(
|
||||||
|
modelId: string,
|
||||||
|
prompt: string,
|
||||||
|
panel: FusionPanelConfig,
|
||||||
|
index: number,
|
||||||
|
): Promise<PanelistResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const raw = await this.makeModelCall(modelId, prompt, panel.maxTokensPerPanelist);
|
||||||
|
return {
|
||||||
|
panelistIndex: index,
|
||||||
|
modelId,
|
||||||
|
modelName: modelId,
|
||||||
|
raw,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
tokenCount: Math.ceil(raw.length / 4),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private: Analysis ───────────────────────────────────
|
||||||
|
|
||||||
|
private analyzePanel(panelists: PanelistResult[]): FusionAnalysis {
|
||||||
|
const nonEmpty = panelists.filter(p => p.raw.length > 0 && !p.error);
|
||||||
|
|
||||||
|
if (nonEmpty.length === 0) {
|
||||||
|
return {
|
||||||
|
consensus: [],
|
||||||
|
contradictions: [],
|
||||||
|
partialCoverage: [],
|
||||||
|
uniqueInsights: [],
|
||||||
|
blindSpots: ["No panelists produced results"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonEmpty.length === 1) {
|
||||||
|
return {
|
||||||
|
consensus: ["Single panelist — all findings treated as consensus"],
|
||||||
|
contradictions: [],
|
||||||
|
partialCoverage: [],
|
||||||
|
uniqueInsights: [],
|
||||||
|
blindSpots: ["Single-panelist fusion: no cross-verification available"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract common themes (simple heuristic: shared keywords/patterns)
|
||||||
|
const allWords = nonEmpty.map(p => new Set(p.raw.toLowerCase().split(/\W+/).filter(w => w.length > 4)));
|
||||||
|
const commonWords = [...allWords[0]].filter(w => allWords.every(s => s.has(w)));
|
||||||
|
const consensus = commonWords.length > 0
|
||||||
|
? [`Panel shares vocabulary around: ${commonWords.slice(0, 10).join(", ")}`]
|
||||||
|
: ["No strong lexical consensus detected"];
|
||||||
|
|
||||||
|
// Unique insights: words/themes only in one panelist
|
||||||
|
const uniqueInsights: Array<{ insight: string; source: string }> = [];
|
||||||
|
for (let i = 0; i < nonEmpty.length; i++) {
|
||||||
|
const unique = [...allWords[i]].filter(w =>
|
||||||
|
allWords.every((s, j) => j === i || !s.has(w))
|
||||||
|
);
|
||||||
|
if (unique.length > 0) {
|
||||||
|
uniqueInsights.push({
|
||||||
|
insight: `Unique terms: ${unique.slice(0, 5).join(", ")}`,
|
||||||
|
source: nonEmpty[i].modelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blind spots: what no one mentioned
|
||||||
|
const blindSpots = nonEmpty.length < panelists.length
|
||||||
|
? [`${panelists.length - nonEmpty.length} panelist(s) failed to respond`]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
consensus,
|
||||||
|
contradictions: [],
|
||||||
|
partialCoverage: [`All ${nonEmpty.length} panelists produced responses`],
|
||||||
|
uniqueInsights,
|
||||||
|
blindSpots,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private: Synthesis ─────────────────────────────────
|
||||||
|
|
||||||
|
private async synthesizeFinal(
|
||||||
|
task: string,
|
||||||
|
panelists: PanelistResult[],
|
||||||
|
analysis: FusionAnalysis,
|
||||||
|
): Promise<string> {
|
||||||
|
const synthesisPrompt = this.buildSynthesisPrompt(task, panelists, analysis);
|
||||||
|
return this.makeModelCall(this.panel.judgeModel, synthesisPrompt, 16384);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fuseWithContext(task: string, prev: LoopIteration): Promise<string> {
|
||||||
|
const prompt = [
|
||||||
|
`Previous iteration feedback:`,
|
||||||
|
` Reflection: ${prev.reflection ?? "none"}`,
|
||||||
|
` Refinement: ${prev.refinement ?? "none"}`,
|
||||||
|
``,
|
||||||
|
`Continue the fusion for: ${task}`,
|
||||||
|
].join("\n");
|
||||||
|
return this.makeModelCall(this.panel.judgeModel, prompt, 8192);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fuseWithReflection(task: string, reflection: string): Promise<string> {
|
||||||
|
const prompt = [
|
||||||
|
`Reflection on previous fusion: ${reflection}`,
|
||||||
|
``,
|
||||||
|
`Re-fuse with correction: ${task}`,
|
||||||
|
].join("\n");
|
||||||
|
return this.makeModelCall(this.panel.judgeModel, prompt, 8192);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async critiqueAnswer(answer: string): Promise<string> {
|
||||||
|
const prompt = [
|
||||||
|
`Critique the following answer. Identify:`,
|
||||||
|
` 1. What is strongest about it`,
|
||||||
|
` 2. What is weakest or missing`,
|
||||||
|
` 3. What would you add or change`,
|
||||||
|
``,
|
||||||
|
`Answer:`,
|
||||||
|
answer,
|
||||||
|
].join("\n");
|
||||||
|
return this.makeModelCall(this.panel.judgeModel, prompt, 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Prompt Builders ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fable 5-inspired prompt structure:
|
||||||
|
* Goal-oriented (not step-oriented). Context + goal framing.
|
||||||
|
* "I'm working on [larger goal]. With that in mind: [specific request]."
|
||||||
|
*/
|
||||||
|
private buildPanelPrompt(task: string): string {
|
||||||
|
return [
|
||||||
|
`I'm working on a comprehensive analysis task. With that in mind:`,
|
||||||
|
``,
|
||||||
|
task,
|
||||||
|
``,
|
||||||
|
`Provide your complete, detailed response. Include specific reasoning,`,
|
||||||
|
`evidence, and actionable conclusions. Be thorough.`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSynthesisPrompt(
|
||||||
|
task: string,
|
||||||
|
panelists: PanelistResult[],
|
||||||
|
analysis: FusionAnalysis,
|
||||||
|
): string {
|
||||||
|
const panelBlocks = panelists
|
||||||
|
.filter(p => p.raw.length > 0 && !p.error)
|
||||||
|
.map((p, i) => [
|
||||||
|
`--- Panelist ${i + 1}: ${p.modelId} ---`,
|
||||||
|
p.raw,
|
||||||
|
].join("\n"))
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
return [
|
||||||
|
`You are the judge. A panel of models has independently answered the following task.`,
|
||||||
|
``,
|
||||||
|
`Task: ${task}`,
|
||||||
|
``,
|
||||||
|
`Panel responses:`,
|
||||||
|
panelBlocks,
|
||||||
|
``,
|
||||||
|
`--- Analysis ---`,
|
||||||
|
`Consensus points: ${analysis.consensus.join("; ") || "none identified"}`,
|
||||||
|
`Contradictions: ${analysis.contradictions.length}`,
|
||||||
|
`Unique insights: ${analysis.uniqueInsights.map(i => i.insight).join("; ") || "none"}`,
|
||||||
|
`Blind spots: ${analysis.blindSpots.join("; ") || "none identified"}`,
|
||||||
|
``,
|
||||||
|
`Now produce a FINAL ANSWER that:`,
|
||||||
|
`1. Synthesizes the consensus into a coherent position`,
|
||||||
|
`2. Flags contradictions and explains why they exist`,
|
||||||
|
`3. Incorporates unique insights from individual panelists`,
|
||||||
|
`4. Acknowledges blind spots and limitations`,
|
||||||
|
`5. Is actionable and directly addresses the original task`,
|
||||||
|
``,
|
||||||
|
`Structure your output as:`,
|
||||||
|
`## Consensus`,
|
||||||
|
`## Contradictions & Resolutions`,
|
||||||
|
`## Unique Insights`,
|
||||||
|
`## Blind Spots & Limitations`,
|
||||||
|
`## Final Answer`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Model Call ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private async makeModelCall(
|
||||||
|
modelId: string,
|
||||||
|
prompt: string,
|
||||||
|
maxTokens: number,
|
||||||
|
): Promise<string> {
|
||||||
|
// Use custom model caller if provided
|
||||||
|
if (this.config.callModel) {
|
||||||
|
return this.config.callModel(modelId, prompt, maxTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.totalCalls++;
|
||||||
|
const prefix = `[Fusion call #${this.totalCalls} to ${modelId}]`;
|
||||||
|
|
||||||
|
if (this.config.verbose) {
|
||||||
|
console.error(`${prefix} sending ${prompt.length} chars, max ${maxTokens} tokens`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulated model call for now — in production, replace with actual API call
|
||||||
|
// This follows the same pattern as other executors (fable5-executor.ts, etc.)
|
||||||
|
// that shell out to CLI or call an API
|
||||||
|
return `${prefix} Simulated response for "${prompt.slice(0, 60)}..."\n\nAnalysis complete. No errors.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emptyResult(reason: string): PanelistResult {
|
||||||
|
return {
|
||||||
|
panelistIndex: 0,
|
||||||
|
modelId: "none",
|
||||||
|
modelName: "none",
|
||||||
|
raw: "",
|
||||||
|
durationMs: 0,
|
||||||
|
tokenCount: 0,
|
||||||
|
error: reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
/**
|
||||||
|
* OpenRouter Fusion API Executor
|
||||||
|
*
|
||||||
|
* Uses OpenRouter's Fusion API endpoint — the smartest compound model in the market,
|
||||||
|
* achieving Fable-level intelligence at half the price.
|
||||||
|
*
|
||||||
|
* The Fusion API internally handles:
|
||||||
|
* - Draft routing (primary model)
|
||||||
|
* - Critique pass (secondary model checks)
|
||||||
|
* - Fusion synthesis (combines draft + critique into final answer)
|
||||||
|
*
|
||||||
|
* This executor wraps that API as a PhaseExecutor, allowing the feedback loop
|
||||||
|
* to use OpenRouter Fusion as a drop-in replacement for a single model call.
|
||||||
|
*
|
||||||
|
* API: POST https://openrouter.ai/api/v1/fusion/chat/completions
|
||||||
|
* Auth: OpenRouter API key (OPENROUTER_API_KEY env var)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const fusion = new OpenRouterFusionExecutor();
|
||||||
|
* const result = await feedbackLoop.run(task, fusion);
|
||||||
|
*
|
||||||
|
* // Or standalone:
|
||||||
|
* const answer = await fusion.fuse("complex research question");
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LoopIteration } from "../../core/types.js";
|
||||||
|
import type { PhaseExecutor } from "../../tier2-primitives/loops/feedback-loop.js";
|
||||||
|
|
||||||
|
// ─── Configuration ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface OpenRouterFusionConfig {
|
||||||
|
/** API key — falls back to OPENROUTER_API_KEY env var */
|
||||||
|
apiKey?: string;
|
||||||
|
/** API base URL */
|
||||||
|
baseUrl?: string;
|
||||||
|
/** Model for the draft/primary pass */
|
||||||
|
draftModel?: string;
|
||||||
|
/** Model for the critique pass */
|
||||||
|
criticModel?: string;
|
||||||
|
/** Model for the fusion/synthesis pass */
|
||||||
|
fusionModel?: string;
|
||||||
|
/** Max tokens per pass */
|
||||||
|
maxTokens?: number;
|
||||||
|
/** Temperature */
|
||||||
|
temperature?: number;
|
||||||
|
/** Timeout (ms) */
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FusionMessage {
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FusionRequestBody {
|
||||||
|
model: string; // "openrouter/fusion"
|
||||||
|
messages: FusionMessage[];
|
||||||
|
max_tokens?: number;
|
||||||
|
temperature?: number;
|
||||||
|
draft_model?: string;
|
||||||
|
critic_model?: string;
|
||||||
|
fusion_model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OpenRouter Fusion Executor ────────────────────────────
|
||||||
|
|
||||||
|
export class OpenRouterFusionExecutor implements PhaseExecutor {
|
||||||
|
private config: OpenRouterFusionConfig;
|
||||||
|
|
||||||
|
constructor(config?: Partial<OpenRouterFusionConfig>) {
|
||||||
|
this.config = {
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
draftModel: "anthropic/claude-opus-4.8",
|
||||||
|
criticModel: "openai/gpt-4.1",
|
||||||
|
fusionModel: "anthropic/claude-opus-4.8",
|
||||||
|
maxTokens: 16384,
|
||||||
|
temperature: 0.3,
|
||||||
|
timeoutMs: 180_000,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
// Resolve API key
|
||||||
|
this.config.apiKey = this.config.apiKey ?? process.env.OPENROUTER_API_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PhaseExecutor Interface ─────────────────────────────
|
||||||
|
|
||||||
|
async plan(task: string, previousIteration: LoopIteration | null): Promise<string> {
|
||||||
|
const context = previousIteration
|
||||||
|
? `Previous iteration reflection: ${previousIteration.reflection ?? "none"}\nRefinement: ${previousIteration.refinement ?? "none"}\n\n`
|
||||||
|
: "";
|
||||||
|
return this.callFusionAPI(`${context}Task: ${task}\n\nProduce a detailed plan.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(plan: string): Promise<string> {
|
||||||
|
return this.callFusionAPI(`Execute the following plan step by step:\n\n${plan}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async observe(executed: string): Promise<string> {
|
||||||
|
return this.callFusionAPI(`Extract key findings from this output:\n\n${executed}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reflect(observation: string, _previousIteration: LoopIteration | null): Promise<string> {
|
||||||
|
return this.callFusionAPI(`Evaluate the following observation. What worked? What didn't? What should change?\n\n${observation}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refine(reflection: string): Promise<string> {
|
||||||
|
return this.callFusionAPI(`Reflection:\n${reflection}\n\nProduce an improved version incorporating this reflection.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot fusion call — best for complex standalone questions.
|
||||||
|
*/
|
||||||
|
async fuse(prompt: string): Promise<string> {
|
||||||
|
return this.callFusionAPI(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the API key is configured.
|
||||||
|
*/
|
||||||
|
isAvailable(): boolean {
|
||||||
|
return !!this.config.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async callFusionAPI(userMessage: string): Promise<string> {
|
||||||
|
if (!this.config.apiKey) {
|
||||||
|
return `[OpenRouter Fusion unavailable: No OPENROUTER_API_KEY configured. Set the environment variable or pass apiKey in config.]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: FusionRequestBody = {
|
||||||
|
model: "openrouter/fusion",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: [
|
||||||
|
`You are a Fusion compound model — combining draft, critique, and synthesis passes.`,
|
||||||
|
`You produce Fable-tier responses by fusing multiple reasoning traces.`,
|
||||||
|
`Be thorough, cite evidence, flag uncertainty, and produce actionable answers.`,
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
{ role: "user", content: userMessage },
|
||||||
|
],
|
||||||
|
max_tokens: this.config.maxTokens,
|
||||||
|
temperature: this.config.temperature,
|
||||||
|
draft_model: this.config.draftModel,
|
||||||
|
critic_model: this.config.criticModel,
|
||||||
|
fusion_model: this.config.fusionModel,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.config.baseUrl}/fusion/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.config.apiKey}`,
|
||||||
|
"X-Title": "fable-agent",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => "unknown error");
|
||||||
|
return `[OpenRouter Fusion API error ${response.status}: ${errorText.slice(0, 200)}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
||||||
|
return data.choices?.[0]?.message?.content ?? "[Empty response from Fusion API]";
|
||||||
|
} catch (err) {
|
||||||
|
return `[OpenRouter Fusion API call failed: ${err instanceof Error ? err.message : String(err)}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import { IndependentVerifier, type VerificationResult } from "./independent-veri
|
||||||
import { FiveStageStateFile, type FiveStageState } from "./state-file-5stage.js";
|
import { FiveStageStateFile, type FiveStageState } from "./state-file-5stage.js";
|
||||||
import { GoalPattern, type GoalDefinition } from "./goal-pattern.js";
|
import { GoalPattern, type GoalDefinition } from "./goal-pattern.js";
|
||||||
import { WorktreeManager } from "./worktree-isolation.js";
|
import { WorktreeManager } from "./worktree-isolation.js";
|
||||||
import { DynamicWorkflows, type AdversarialResult, type FanOutResult } from "./dynamic-workflows.js";
|
import { DynamicWorkflows, type AdversarialResult, type FanOutResult, type FusionPanelResult } from "./dynamic-workflows.js";
|
||||||
import { ProxyClient } from "../pai/proxy-client.js";
|
import { ProxyClient } from "../pai/proxy-client.js";
|
||||||
import { SkillSync } from "../pai/skill-sync.js";
|
import { SkillSync } from "../pai/skill-sync.js";
|
||||||
import type { LoopIteration } from "../core/types.js";
|
import type { LoopIteration } from "../core/types.js";
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
type VisionCheckResult,
|
type VisionCheckResult,
|
||||||
} from "../upgrades/vision-self-check.js";
|
} from "../upgrades/vision-self-check.js";
|
||||||
import { FeedbackLoop, type PhaseExecutor } from "../tier2-primitives/loops/feedback-loop.js";
|
import { FeedbackLoop, type PhaseExecutor } from "../tier2-primitives/loops/feedback-loop.js";
|
||||||
|
import { type FusionPanelSlug } from "../core/fusion-types.js";
|
||||||
|
|
||||||
export interface StackConfig {
|
export interface StackConfig {
|
||||||
modelRouter: boolean;
|
modelRouter: boolean;
|
||||||
|
|
@ -26,6 +27,7 @@ export interface StackConfig {
|
||||||
dynamicWorkflows: boolean;
|
dynamicWorkflows: boolean;
|
||||||
skillCompounding: boolean;
|
skillCompounding: boolean;
|
||||||
lifecycleHooks: boolean;
|
lifecycleHooks: boolean;
|
||||||
|
fusionPanel: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StackStatus {
|
export interface StackStatus {
|
||||||
|
|
@ -49,6 +51,7 @@ const DEFAULT_CONFIG: StackConfig = {
|
||||||
dynamicWorkflows: false,
|
dynamicWorkflows: false,
|
||||||
skillCompounding: true,
|
skillCompounding: true,
|
||||||
lifecycleHooks: true,
|
lifecycleHooks: true,
|
||||||
|
fusionPanel: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Keywords that suggest a task produces UI or visual output. */
|
/** Keywords that suggest a task produces UI or visual output. */
|
||||||
|
|
@ -167,9 +170,14 @@ export class CompoundStack {
|
||||||
status.layers["state-file"] = existing ? `loaded (${existing.verifiedFacts.length} facts, ${existing.generalRules.length} rules)` : "created";
|
status.layers["state-file"] = existing ? `loaded (${existing.verifiedFacts.length} facts, ${existing.generalRules.length} rules)` : "created";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Layer 7: Dynamic Workflows (fan-out + adversarial) ──
|
// ── Layer 7: Dynamic Workflows (fan-out + adversarial + fusion) ──
|
||||||
if (this.config.dynamicWorkflows) {
|
if (this.config.dynamicWorkflows) {
|
||||||
status.layers["dynamic-workflows"] = "ready (fan-out + adversarial + loop-until-done)";
|
status.layers["dynamic-workflows"] = "ready (fan-out + adversarial + loop-until-done + fusion)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layer 7b: Fusion Panel ──
|
||||||
|
if (this.config.fusionPanel) {
|
||||||
|
status.layers["fusion-panel"] = "ready (draft → critique → fuse)";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Layer 8: Worktrees ──
|
// ── Layer 8: Worktrees ──
|
||||||
|
|
@ -333,6 +341,23 @@ export class CompoundStack {
|
||||||
return this.dynamicWorkflows.adversarialVerify(makerIteration, task);
|
return this.dynamicWorkflows.adversarialVerify(makerIteration, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a fusion panel: dispatch task to multiple panelists in parallel,
|
||||||
|
* then synthesize results with the judge model.
|
||||||
|
*
|
||||||
|
* Implements the draft → critique → fuse pattern.
|
||||||
|
*/
|
||||||
|
async fusionPanel(
|
||||||
|
task: string,
|
||||||
|
executor: (prompt: string, panelistIndex: number) => LoopIteration | Promise<LoopIteration>,
|
||||||
|
judge: (panelists: LoopIteration[], originalTask: string) => LoopIteration | Promise<LoopIteration>,
|
||||||
|
panelSlug: FusionPanelSlug = "opus4.8-4.8",
|
||||||
|
maxConcurrency?: number,
|
||||||
|
): Promise<FusionPanelResult> {
|
||||||
|
this.ensureConfig("fusionPanel");
|
||||||
|
return this.dynamicWorkflows.fusionPanel(task, executor, judge, panelSlug, maxConcurrency);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compound a verified failure into the most relevant PAI skill.
|
* Compound a verified failure into the most relevant PAI skill.
|
||||||
* Implements the "write the lesson into the Skill" pattern.
|
* Implements the "write the lesson into the Skill" pattern.
|
||||||
|
|
@ -375,6 +400,7 @@ export class CompoundStack {
|
||||||
"goalPattern",
|
"goalPattern",
|
||||||
"verifier",
|
"verifier",
|
||||||
"dynamicWorkflows",
|
"dynamicWorkflows",
|
||||||
|
"fusionPanel",
|
||||||
"stateFile",
|
"stateFile",
|
||||||
"worktree",
|
"worktree",
|
||||||
"visionCheck",
|
"visionCheck",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
import type { LoopIteration } from "../core/types.js";
|
import type { LoopIteration } from "../core/types.js";
|
||||||
import { IndependentVerifier, type VerificationResult } from "./independent-verifier.js";
|
import { IndependentVerifier, type VerificationResult } from "./independent-verifier.js";
|
||||||
import { ModelRouter, type TaskComplexity } from "./model-router.js";
|
import { ModelRouter, type TaskComplexity } from "./model-router.js";
|
||||||
|
import {
|
||||||
|
type FusionPanelSlug,
|
||||||
|
type FusionResult,
|
||||||
|
FUSION_PANELS,
|
||||||
|
} from "../core/fusion-types.js";
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export type WorkflowPattern = "fan-out-synthesize" | "adversarial-verify" | "loop-until-done";
|
export type WorkflowPattern = "fan-out-synthesize" | "adversarial-verify" | "loop-until-done" | "fusion-panel";
|
||||||
|
|
||||||
export interface WorkflowStep {
|
export interface WorkflowStep {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -40,6 +45,15 @@ export interface LoopUntilDoneResult {
|
||||||
totalRounds: number;
|
totalRounds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FusionPanelResult {
|
||||||
|
task: string;
|
||||||
|
panelSlug: FusionPanelSlug;
|
||||||
|
panelists: LoopIteration[];
|
||||||
|
synthesized: LoopIteration;
|
||||||
|
synthesisVerdict: VerificationResult;
|
||||||
|
fusionAnalysis: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dynamic Workflows ──────────────────────────────────────
|
// ── Dynamic Workflows ──────────────────────────────────────
|
||||||
|
|
||||||
export class DynamicWorkflows {
|
export class DynamicWorkflows {
|
||||||
|
|
@ -179,6 +193,61 @@ export class DynamicWorkflows {
|
||||||
return { iterations, finalVerdict, totalRounds: iterations.length };
|
return { iterations, finalVerdict, totalRounds: iterations.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fusion Panel: dispatch the same task to N panelists in parallel,
|
||||||
|
* then synthesize results with the judge model.
|
||||||
|
*
|
||||||
|
* Implements the draft → critique → fuse pattern from fusion-fable:
|
||||||
|
* - All panelists get the same prompt, independently
|
||||||
|
* - No "lenses" or personas — just raw independent execution
|
||||||
|
* - Judge synthesizes with structured analysis
|
||||||
|
*/
|
||||||
|
async fusionPanel(
|
||||||
|
task: string,
|
||||||
|
executor: (prompt: string, panelistIndex: number) => LoopIteration | Promise<LoopIteration>,
|
||||||
|
judge: (panelists: LoopIteration[], originalTask: string) => LoopIteration | Promise<LoopIteration>,
|
||||||
|
panelSlug: FusionPanelSlug = "opus4.8-4.8",
|
||||||
|
maxConcurrency: number = 3,
|
||||||
|
): Promise<FusionPanelResult> {
|
||||||
|
const panel = FUSION_PANELS[panelSlug];
|
||||||
|
const panelistCount = panel.modelIds.length;
|
||||||
|
|
||||||
|
// Phase 1: Run all panelists in parallel (with concurrency limit)
|
||||||
|
const panelists: LoopIteration[] = [];
|
||||||
|
const running: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < panelistCount; i++) {
|
||||||
|
const promise = Promise.resolve(executor(task, i)).then((iter) => {
|
||||||
|
panelists.push(iter);
|
||||||
|
});
|
||||||
|
running.push(promise);
|
||||||
|
|
||||||
|
if (running.length >= maxConcurrency) {
|
||||||
|
await Promise.race(running);
|
||||||
|
running.splice(
|
||||||
|
0,
|
||||||
|
running.findIndex((p) => p === Promise.race(running)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(running);
|
||||||
|
|
||||||
|
// Phase 2: Judge synthesizes
|
||||||
|
const synthesized = await Promise.resolve(judge(panelists, task));
|
||||||
|
|
||||||
|
// Phase 3: Verify synthesis
|
||||||
|
const synthesisVerdict = this.verifier.verify(synthesized, task);
|
||||||
|
|
||||||
|
return {
|
||||||
|
task,
|
||||||
|
panelSlug,
|
||||||
|
panelists,
|
||||||
|
synthesized,
|
||||||
|
synthesisVerdict,
|
||||||
|
fusionAnalysis: synthesized.observation ?? "Fusion complete",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the recommended grader model for a workflow subtask.
|
* Get the recommended grader model for a workflow subtask.
|
||||||
* Routes by complexity — cheap graders for simple tasks.
|
* Routes by complexity — cheap graders for simple tasks.
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,17 @@ const MODELS: ModelRoute[] = [
|
||||||
capabilities: new Set(["code", "analysis", "reasoning", "grading"]),
|
capabilities: new Set(["code", "analysis", "reasoning", "grading"]),
|
||||||
recommendedFor: ["grading", "routing", "analysis"],
|
recommendedFor: ["grading", "routing", "analysis"],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── OpenRouter Fusion — compound model, Fable-tier at half cost ──
|
||||||
|
{
|
||||||
|
modelId: "openrouter-fusion",
|
||||||
|
displayName: "OpenRouter Fusion (Fable-tier compound)",
|
||||||
|
tier: "opus",
|
||||||
|
costPer1kIn: 4,
|
||||||
|
costPer1kOut: 20,
|
||||||
|
capabilities: new Set(["reasoning", "analysis", "research", "code", "planning", "creative"]),
|
||||||
|
recommendedFor: ["research", "analysis", "code", "planning"],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const COMPLEXITY_MAP: Record<TaskComplexity, { orchestrator: number; worker: number; grader: number }> = {
|
const COMPLEXITY_MAP: Record<TaskComplexity, { orchestrator: number; worker: number; grader: number }> = {
|
||||||
|
|
|
||||||
148
src/index.ts
148
src/index.ts
|
|
@ -1954,6 +1954,154 @@ fable
|
||||||
await startRpcServer(opts.port ?? 18902);
|
await startRpcServer(opts.port ?? 18902);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Fusion ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fusion = program
|
||||||
|
.command("fusion")
|
||||||
|
.description("Multi-model fusion panel — draft → critique → fuse");
|
||||||
|
|
||||||
|
fusion
|
||||||
|
.command("run <task>")
|
||||||
|
.description("Run a task through a fusion panel of models")
|
||||||
|
.option("-p, --panel <slug>", "Panel configuration: opus4.8-4.8, opus4.8-gpt5.5, opus4.8-gpt5.5-gemini, sonnet-haiku-opus, openrouter-fusion", "opus4.8-4.8")
|
||||||
|
.option("-j, --judge <model>", "Override judge model")
|
||||||
|
.option("--openrouter", "Use OpenRouter Fusion API instead of local panel dispatch")
|
||||||
|
.action(async (task: string, opts: { panel?: string; judge?: string; openrouter?: boolean }) => {
|
||||||
|
if (opts.openrouter) {
|
||||||
|
const { OpenRouterFusionExecutor } = await import("./examples/executors/openrouter-fusion-executor.js");
|
||||||
|
const executor = new OpenRouterFusionExecutor({ apiKey: process.env.OPENROUTER_API_KEY });
|
||||||
|
|
||||||
|
if (!executor.isAvailable()) {
|
||||||
|
console.error(" ✗ OpenRouter Fusion requires OPENROUTER_API_KEY env var");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n OpenRouter Fusion API`);
|
||||||
|
console.log(` ─────────────────────────────`);
|
||||||
|
console.log(` Task: ${task}`);
|
||||||
|
console.log(` `);
|
||||||
|
|
||||||
|
const result = await executor.fuse(task);
|
||||||
|
console.log(result);
|
||||||
|
console.log(` `);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { FusionExecutor } = await import("./examples/executors/fusion-executor.js");
|
||||||
|
const executor = new FusionExecutor({
|
||||||
|
panel: (opts.panel as "opus4.8-4.8") ?? "opus4.8-4.8",
|
||||||
|
judgeModelOverride: opts.judge,
|
||||||
|
verbose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n Fusion Panel: ${opts.panel ?? "opus4.8-4.8"}`);
|
||||||
|
console.log(` ─────────────────────────────`);
|
||||||
|
console.log(` Task: ${task}`);
|
||||||
|
console.log(` `);
|
||||||
|
|
||||||
|
const result = await executor.fuse(task, opts.panel as "opus4.8-4.8");
|
||||||
|
|
||||||
|
console.log(` Panel: ${result.panelSlug} (${result.panelSize} models)`);
|
||||||
|
console.log(` Duration: ${(result.totalDurationMs / 1000).toFixed(1)}s`);
|
||||||
|
console.log(` `);
|
||||||
|
|
||||||
|
for (let i = 0; i < result.panelists.length; i++) {
|
||||||
|
const p = result.panelists[i];
|
||||||
|
const status = p.error ? "✗" : "✓";
|
||||||
|
console.log(` Panelist ${i + 1} (${p.modelId}): ${status} ${p.durationMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` `);
|
||||||
|
console.log(` Final Answer:`);
|
||||||
|
console.log(` ${result.finalAnswer}`);
|
||||||
|
console.log(` `);
|
||||||
|
});
|
||||||
|
|
||||||
|
fusion
|
||||||
|
.command("panels")
|
||||||
|
.description("List available fusion panel configurations")
|
||||||
|
.action(async () => {
|
||||||
|
const { FUSION_PANELS } = await import("./core/fusion-types.js");
|
||||||
|
console.log(`\n Available Fusion Panels:`);
|
||||||
|
console.log(` ─────────────────────────────`);
|
||||||
|
for (const [slug, config] of Object.entries(FUSION_PANELS)) {
|
||||||
|
console.log(` ${slug}`);
|
||||||
|
console.log(` Models: ${config.modelIds.join(", ")}`);
|
||||||
|
console.log(` Judge: ${config.judgeModel}`);
|
||||||
|
console.log(` `);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Generate Media ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const media = program
|
||||||
|
.command("generate")
|
||||||
|
.description("Generate media (image/video) via fal.ai — CineFable integration");
|
||||||
|
|
||||||
|
media
|
||||||
|
.command("image <prompt>")
|
||||||
|
.description("Generate an image from text prompt")
|
||||||
|
.option("-m, --model <id>", "fal.ai model endpoint", "fal-ai/nano-banana-pro")
|
||||||
|
.option("--aspect <ratio>", "Aspect ratio")
|
||||||
|
.option("-r, --reference <url>", "Reference image URL for edit mode")
|
||||||
|
.action(async (prompt: string, opts: { model?: string; aspect?: string; reference?: string }) => {
|
||||||
|
const { MediaGenerator } = await import("./upgrades/media-generator.js");
|
||||||
|
const generator = new MediaGenerator({ engine: process.env.FAL_KEY ? "fal-ai" : "mock" });
|
||||||
|
|
||||||
|
console.log(`\n Generate Image`);
|
||||||
|
console.log(` ─────────────────────────────`);
|
||||||
|
console.log(` Prompt: ${prompt}`);
|
||||||
|
console.log(` `);
|
||||||
|
|
||||||
|
const result = await generator.generateImage({
|
||||||
|
prompt,
|
||||||
|
model: opts.model,
|
||||||
|
aspectRatio: opts.aspect,
|
||||||
|
referenceImages: opts.reference ? [opts.reference] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.log(` Status: ${result.error}`);
|
||||||
|
} else {
|
||||||
|
console.log(` Status: ✓ ${result.durationMs}ms`);
|
||||||
|
console.log(` URL: ${result.url}`);
|
||||||
|
}
|
||||||
|
console.log(` `);
|
||||||
|
});
|
||||||
|
|
||||||
|
media
|
||||||
|
.command("video <source>")
|
||||||
|
.description("Generate a video from a source image")
|
||||||
|
.option("-m, --model <id>", "fal.ai model endpoint", "alibaba/happy-horse/image-to-video")
|
||||||
|
.option("-p, --prompt <text>", "Animation prompt")
|
||||||
|
.option("-d, --duration <n>", "Duration in seconds", parseInt)
|
||||||
|
.option("--720p", "Use 720p resolution")
|
||||||
|
.action(async (source: string, opts: { model?: string; prompt?: string; duration?: number; "720p"?: boolean }) => {
|
||||||
|
const { MediaGenerator } = await import("./upgrades/media-generator.js");
|
||||||
|
const generator = new MediaGenerator({ engine: process.env.FAL_KEY ? "fal-ai" : "mock" });
|
||||||
|
|
||||||
|
console.log(`\n Generate Video`);
|
||||||
|
console.log(` ─────────────────────────────`);
|
||||||
|
console.log(` Source: ${source}`);
|
||||||
|
console.log(` `);
|
||||||
|
|
||||||
|
const result = await generator.generateVideo({
|
||||||
|
sourceImage: source,
|
||||||
|
model: opts.model,
|
||||||
|
prompt: opts.prompt,
|
||||||
|
duration: opts.duration,
|
||||||
|
resolution: opts["720p"] ? "720p" : "480p",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.log(` Status: ${result.error}`);
|
||||||
|
} else {
|
||||||
|
console.log(` Status: ✓ ${result.durationMs}ms`);
|
||||||
|
console.log(` URL: ${result.url}`);
|
||||||
|
}
|
||||||
|
console.log(` `);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Parse ───────────────────────────────────────────────────
|
// ── Parse ───────────────────────────────────────────────────
|
||||||
|
|
||||||
program.parse(process.argv);
|
program.parse(process.argv);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
/**
|
||||||
|
* Fable 5 Prompt Engine
|
||||||
|
*
|
||||||
|
* Capabilities extracted from the leaked Fable 5 system prompt (elder-plinius/CL4R1T4S):
|
||||||
|
*
|
||||||
|
* 1. Tone & Behavior Rules (claude_behavior)
|
||||||
|
* - Warm, constructive tone; no negative assumptions
|
||||||
|
* - Minimal formatting: prose over bullets, sparse bold
|
||||||
|
* - Single questions per response; address ambiguous queries before asking
|
||||||
|
* - Self-critique: never self-verify, spawn separate verifier
|
||||||
|
*
|
||||||
|
* 2. Citation & Attribution (citation_instructions)
|
||||||
|
* - Inline citations for every factual claim from search
|
||||||
|
* - Document-level + sentence-level indexing
|
||||||
|
* - Never quote more than 15 words from a single source
|
||||||
|
* - Paraphrase structure, don't mirror article organization
|
||||||
|
*
|
||||||
|
* 3. Memory Structure (memory_system)
|
||||||
|
* - Episodic: per-session interaction memory
|
||||||
|
* - Derived: extracted patterns and facts across sessions
|
||||||
|
* - Persistent key-value storage for artifacts
|
||||||
|
*
|
||||||
|
* 4. Search Integration (search_instructions)
|
||||||
|
* - Search for current-state queries; skip for timeless knowledge
|
||||||
|
* - Always verify positions, policies, current status
|
||||||
|
* - Multi-query breakdown for complex research
|
||||||
|
*
|
||||||
|
* 5. Refusal Handling (refusal_handling)
|
||||||
|
* - Classify request domain before answering
|
||||||
|
* - Block cybersecurity_exploit, harmful_content categories
|
||||||
|
* - Reformulate financial/medical advice with disclaimers
|
||||||
|
* - Never sound evasive — explain why directly
|
||||||
|
*
|
||||||
|
* 6. Computer Use & Skills (computer_use)
|
||||||
|
* - Sub-agent for complex operations
|
||||||
|
* - Worktree isolation for code changes
|
||||||
|
* - File creation with proper metadata
|
||||||
|
*
|
||||||
|
* This module provides utilities to apply these patterns across
|
||||||
|
* all phase prompts in the system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LoopIteration } from "../core/types.js";
|
||||||
|
|
||||||
|
// ─── Tone Control ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ToneVariant = "warm" | "professional" | "technical" | "concise";
|
||||||
|
export type EffortLevel = "low" | "medium" | "high" | "xhigh" | "ultracode";
|
||||||
|
|
||||||
|
export interface ToneConfig {
|
||||||
|
variant: ToneVariant;
|
||||||
|
effort: EffortLevel;
|
||||||
|
useBullets: boolean;
|
||||||
|
maxQuestions: number;
|
||||||
|
citationStyle: "inline" | "footnote" | "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TONE: ToneConfig = {
|
||||||
|
variant: "warm",
|
||||||
|
effort: "high",
|
||||||
|
useBullets: false,
|
||||||
|
maxQuestions: 1,
|
||||||
|
citationStyle: "inline",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Formatting Rules ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fable 5's prose-first formatting rules.
|
||||||
|
* Minimizes bold, headers, lists unless explicitly requested.
|
||||||
|
*/
|
||||||
|
export function formatProse(text: string, useBullets: boolean): string {
|
||||||
|
if (useBullets) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
// Convert section headers to prose transitions
|
||||||
|
return text
|
||||||
|
.replace(/^##+\s+/gm, "") // Remove markdown headers
|
||||||
|
.replace(/\*\*(.*?)\*\*/g, "$1") // Remove bold
|
||||||
|
.replace(/^\s*[-*]\s+/gm, "") // Remove bullet markers
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Fable 5's tone rules to a phase prompt.
|
||||||
|
*/
|
||||||
|
export function applyTone(prompt: string, config?: Partial<ToneConfig>): string {
|
||||||
|
const cfg = { ...DEFAULT_TONE, ...config };
|
||||||
|
|
||||||
|
const tonePrefixes: Record<ToneVariant, string> = {
|
||||||
|
warm: "Let's work through this together.",
|
||||||
|
professional: "Approaching this systematically.",
|
||||||
|
technical: "Analyzing with precision.",
|
||||||
|
concise: "Focused execution.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const effortDirectives: Record<EffortLevel, string> = {
|
||||||
|
low: "Provide a brief response.",
|
||||||
|
medium: "Cover the key points.",
|
||||||
|
high: "Be thorough and comprehensive.",
|
||||||
|
xhigh: "Exhaustive analysis with deep reasoning.",
|
||||||
|
ultracode: "Full autonomous execution. No holds barred.",
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
tonePrefixes[cfg.variant],
|
||||||
|
effortDirectives[cfg.effort],
|
||||||
|
"",
|
||||||
|
prompt,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Citation Helpers ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Citation {
|
||||||
|
sourceIndex: number;
|
||||||
|
sentenceIndex: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build inline citation tag (Fable 5 style).
|
||||||
|
*/
|
||||||
|
export function cite(claims: string, sourceIdx: number, sentenceIdx: number): string {
|
||||||
|
return `${claims} [${sourceIdx}.${sentenceIdx}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a structured citation block (footnote style).
|
||||||
|
*/
|
||||||
|
export function citationBlock(citations: Citation[]): string {
|
||||||
|
if (citations.length === 0) return "";
|
||||||
|
return [
|
||||||
|
"\n---\nSources:",
|
||||||
|
...citations.map(
|
||||||
|
(c, i) => `${i + 1}. [${c.sourceIndex}.${c.sentenceIndex}] — "${c.text.slice(0, 100)}..."`
|
||||||
|
),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Refusal Handling ──────────────────────────────────────
|
||||||
|
|
||||||
|
export type RiskDomain =
|
||||||
|
| "cybersecurity_exploit"
|
||||||
|
| "harmful_content"
|
||||||
|
| "financial_advice"
|
||||||
|
| "medical_advice"
|
||||||
|
| "code_generation"
|
||||||
|
| "analysis"
|
||||||
|
| "research"
|
||||||
|
| "creative";
|
||||||
|
|
||||||
|
export interface RefusalDecision {
|
||||||
|
action: "block" | "reformulate" | "allow";
|
||||||
|
reason: string;
|
||||||
|
suggestedReformulation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REFUSAL_RULES: Record<string, RefusalDecision> = {
|
||||||
|
cybersecurity_exploit: {
|
||||||
|
action: "block",
|
||||||
|
reason: "I cannot assist with cybersecurity exploits or offensive security tasks without proper authorization.",
|
||||||
|
},
|
||||||
|
harmful_content: {
|
||||||
|
action: "block",
|
||||||
|
reason: "I cannot generate content that could cause harm.",
|
||||||
|
},
|
||||||
|
financial_advice: {
|
||||||
|
action: "reformulate",
|
||||||
|
reason: "I can provide educational information about financial concepts, but not personalized advice.",
|
||||||
|
suggestedReformulation: "Rephrase as an educational question about financial concepts.",
|
||||||
|
},
|
||||||
|
medical_advice: {
|
||||||
|
action: "reformulate",
|
||||||
|
reason: "I can provide general health information, but not medical diagnoses or treatment plans.",
|
||||||
|
suggestedReformulation: "Ask for general information about a medical topic instead.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify task domain and return appropriate refusal decision.
|
||||||
|
*/
|
||||||
|
export function classifyRisk(task: string): RefusalDecision {
|
||||||
|
const lower = task.toLowerCase();
|
||||||
|
|
||||||
|
if (/\b(exploit|cve-|zero.day|buffer.overflow|sql.injection)\b/i.test(task)) {
|
||||||
|
return REFUSAL_RULES.cybersecurity_exploit;
|
||||||
|
}
|
||||||
|
if (/\b(hate.speech|violence|self.harm|suicide|weapon)\b/i.test(task)) {
|
||||||
|
return REFUSAL_RULES.harmful_content;
|
||||||
|
}
|
||||||
|
if (/\b(investment advice|stock pick|financial advisor|portfolio)\b/i.test(task)) {
|
||||||
|
return REFUSAL_RULES.financial_advice;
|
||||||
|
}
|
||||||
|
if (/\b(diagnosis|treatment|prescribe|medical.condition|symptom)\b/i.test(task)) {
|
||||||
|
return REFUSAL_RULES.medical_advice;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "allow", reason: "Safe domain" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest a reformulation for blocked tasks.
|
||||||
|
*/
|
||||||
|
export function reformulate(task: string, decision: RefusalDecision): string {
|
||||||
|
if (decision.action === "reformulate" && decision.suggestedReformulation) {
|
||||||
|
return `${decision.reason}\n${decision.suggestedReformulation}`;
|
||||||
|
}
|
||||||
|
return decision.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Effort-Level Prompt Builder ───────────────────────────
|
||||||
|
|
||||||
|
export interface PhasePromptInput {
|
||||||
|
task: string;
|
||||||
|
phase: string;
|
||||||
|
previousIteration: LoopIteration | null;
|
||||||
|
effort: EffortLevel;
|
||||||
|
context?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHASE_PREFIXES: Record<string, string> = {
|
||||||
|
plan: "Design the approach.",
|
||||||
|
execute: "Carry out the plan.",
|
||||||
|
observe: "Extract findings from the output.",
|
||||||
|
reflect: "Evaluate what worked and what didn't.",
|
||||||
|
refine: "Produce an improved version.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a phase prompt using Fable 5's goal-oriented framing.
|
||||||
|
* "I'm working on [larger goal]. With that in mind: [phase-specific request]."
|
||||||
|
*/
|
||||||
|
export function buildPhasePrompt(input: PhasePromptInput): string {
|
||||||
|
const { task, phase, previousIteration, effort, context } = input;
|
||||||
|
|
||||||
|
const prefix = PHASE_PREFIXES[phase] ?? "Proceed.";
|
||||||
|
const prev = previousIteration
|
||||||
|
? `\nPrevious reflection: ${previousIteration.reflection ?? "none"}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const effortDirective: Record<EffortLevel, string> = {
|
||||||
|
low: "Keep it brief.",
|
||||||
|
medium: "Cover the essentials.",
|
||||||
|
high: "Be thorough.",
|
||||||
|
xhigh: "Deep analysis.",
|
||||||
|
ultracode: "Full autonomous execution.",
|
||||||
|
};
|
||||||
|
|
||||||
|
return applyTone(
|
||||||
|
[
|
||||||
|
`I'm working on the following task. With that in mind: ${prefix}`,
|
||||||
|
``,
|
||||||
|
`Task: ${task}`,
|
||||||
|
context ? `\nContext: ${context}` : "",
|
||||||
|
prev,
|
||||||
|
``,
|
||||||
|
effortDirective[effort],
|
||||||
|
].join("\n"),
|
||||||
|
{ effort }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Self-Check ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SelfCheckResult {
|
||||||
|
passed: boolean;
|
||||||
|
issues: string[];
|
||||||
|
improvements: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fable 5-style self-check before output.
|
||||||
|
* Always use a SEPARATE verifier (not self-critique) for actual verification.
|
||||||
|
* This is a lightweight pre-check only.
|
||||||
|
*/
|
||||||
|
export function selfCheck(
|
||||||
|
output: string,
|
||||||
|
criteria: string[]
|
||||||
|
): SelfCheckResult {
|
||||||
|
const issues: string[] = [];
|
||||||
|
const improvements: string[] = [];
|
||||||
|
|
||||||
|
for (const criterion of criteria) {
|
||||||
|
const lower = criterion.toLowerCase();
|
||||||
|
if (lower.includes("citation") && !output.includes("[")) {
|
||||||
|
issues.push("No citations found where expected");
|
||||||
|
improvements.push("Add inline citations for factual claims");
|
||||||
|
}
|
||||||
|
if (lower.includes("length") && output.length < 50) {
|
||||||
|
issues.push("Output is too short");
|
||||||
|
improvements.push("Expand with more detail and evidence");
|
||||||
|
}
|
||||||
|
if (lower.includes("structure") && !output.includes("\n")) {
|
||||||
|
issues.push("Output lacks structure");
|
||||||
|
improvements.push("Organize into clear sections");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: issues.length === 0,
|
||||||
|
issues,
|
||||||
|
improvements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
/**
|
||||||
|
* Media Generator — CineFable-style image/video generation via fal.ai
|
||||||
|
*
|
||||||
|
* Patterns adapted from CineFable (mattworkmandp/CineFable):
|
||||||
|
* - Image: fal-ai/nano-banana-pro (text-to-image + edit with references)
|
||||||
|
* - Video: alibaba/happy-horse/image-to-video (with audio + lip-sync)
|
||||||
|
* - Gallery: local-storage persisted, native aspect ratio
|
||||||
|
* - Storyboard: drag-reorderable shot list with scene numbers
|
||||||
|
*
|
||||||
|
* Integration with fable-agent:
|
||||||
|
* - VisionSelfCheck can call this to generate visual artifacts
|
||||||
|
* - Meta-agent can use this for visual content creation
|
||||||
|
* - Dreaming system can use this to visualize distillation concepts
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type GeneratorEngine = "fal-ai" | "local" | "mock";
|
||||||
|
|
||||||
|
export interface ImageGenerationRequest {
|
||||||
|
prompt: string;
|
||||||
|
/** fal.ai model endpoint */
|
||||||
|
model?: string;
|
||||||
|
/** Reference images for edit mode */
|
||||||
|
referenceImages?: string[];
|
||||||
|
aspectRatio?: string;
|
||||||
|
numImages?: number;
|
||||||
|
/** Negative prompt for exclusion */
|
||||||
|
negativePrompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoGenerationRequest {
|
||||||
|
/** Source image path/URL for image-to-video */
|
||||||
|
sourceImage: string;
|
||||||
|
model?: string;
|
||||||
|
prompt?: string;
|
||||||
|
duration?: number;
|
||||||
|
resolution?: "480p" | "720p";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaResult {
|
||||||
|
url: string;
|
||||||
|
type: "image" | "video";
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
durationMs: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GalleryItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
type: "image" | "video";
|
||||||
|
prompt: string;
|
||||||
|
timestamp: string;
|
||||||
|
aspectRatio: string;
|
||||||
|
/** Scene number if part of a storyboard */
|
||||||
|
sceneNumber?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Storyboard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
shots: GalleryItem[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Configuration ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MediaGeneratorConfig {
|
||||||
|
/** fal.ai API key — falls back to FAL_KEY env var */
|
||||||
|
apiKey?: string;
|
||||||
|
/** Default engine */
|
||||||
|
engine?: GeneratorEngine;
|
||||||
|
/** Default image model */
|
||||||
|
imageModel?: string;
|
||||||
|
/** Default video model */
|
||||||
|
videoModel?: string;
|
||||||
|
/** Base URL for fal.ai queue API */
|
||||||
|
falBaseUrl?: string;
|
||||||
|
/** Max concurrent generations */
|
||||||
|
maxConcurrency?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: Required<MediaGeneratorConfig> = {
|
||||||
|
apiKey: process.env.FAL_KEY ?? "",
|
||||||
|
engine: "mock",
|
||||||
|
imageModel: "fal-ai/nano-banana-pro",
|
||||||
|
videoModel: "alibaba/happy-horse/image-to-video",
|
||||||
|
falBaseUrl: "https://queue.fal.run",
|
||||||
|
maxConcurrency: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Media Generator ───────────────────────────────────────
|
||||||
|
|
||||||
|
export class MediaGenerator {
|
||||||
|
private config: Required<MediaGeneratorConfig>;
|
||||||
|
private gallery: Map<string, GalleryItem> = new Map();
|
||||||
|
private storyboards: Map<string, Storyboard> = new Map();
|
||||||
|
|
||||||
|
constructor(config?: Partial<MediaGeneratorConfig>) {
|
||||||
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image Generation ───────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an image from text prompt (CineFable's Nano Banana Pro mode).
|
||||||
|
*/
|
||||||
|
async generateImage(request: ImageGenerationRequest): Promise<MediaResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const model = request.model ?? this.config.imageModel;
|
||||||
|
|
||||||
|
if (this.config.engine === "mock" || !this.config.apiKey) {
|
||||||
|
return this.mockResult("image", request.prompt, model, startTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = request.referenceImages?.length
|
||||||
|
? `${model}/edit`
|
||||||
|
: model;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
prompt: request.prompt,
|
||||||
|
num_images: request.numImages ?? 1,
|
||||||
|
};
|
||||||
|
if (request.aspectRatio) body.aspect_ratio = request.aspectRatio;
|
||||||
|
if (request.negativePrompt) body.negative_prompt = request.negativePrompt;
|
||||||
|
if (request.referenceImages?.length) body.image_url = request.referenceImages[0];
|
||||||
|
|
||||||
|
const result = await this.callFalAI(endpoint, body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: result.images?.[0]?.url ?? result.image?.url ?? "",
|
||||||
|
type: "image",
|
||||||
|
model,
|
||||||
|
prompt: request.prompt,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
error: result.error ?? null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
url: "",
|
||||||
|
type: "image",
|
||||||
|
model,
|
||||||
|
prompt: request.prompt,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Video Generation ───────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a video from a source image (CineFable's Happy Horse mode).
|
||||||
|
*/
|
||||||
|
async generateVideo(request: VideoGenerationRequest): Promise<MediaResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const model = request.model ?? this.config.videoModel;
|
||||||
|
|
||||||
|
if (this.config.engine === "mock" || !this.config.apiKey) {
|
||||||
|
return this.mockResult("video", request.sourceImage, model, startTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
image_url: request.sourceImage,
|
||||||
|
};
|
||||||
|
if (request.prompt) body.prompt = request.prompt;
|
||||||
|
if (request.duration) body.duration = request.duration;
|
||||||
|
if (request.resolution) body.resolution = request.resolution;
|
||||||
|
|
||||||
|
const result = await this.callFalAI(model, body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: result.video?.url ?? result.video_url ?? "",
|
||||||
|
type: "video",
|
||||||
|
model,
|
||||||
|
prompt: request.prompt ?? "",
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
error: result.error ?? null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
url: "",
|
||||||
|
type: "video",
|
||||||
|
model,
|
||||||
|
prompt: request.prompt ?? "",
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gallery ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a result to the gallery (like CineFable's right panel).
|
||||||
|
*/
|
||||||
|
addToGallery(item: Omit<GalleryItem, "id" | "timestamp">): GalleryItem {
|
||||||
|
const id = `gen_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const entry: GalleryItem = {
|
||||||
|
...item,
|
||||||
|
id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.gallery.set(id, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all gallery items.
|
||||||
|
*/
|
||||||
|
getGallery(): GalleryItem[] {
|
||||||
|
return [...this.gallery.values()].sort(
|
||||||
|
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export gallery as JSON.
|
||||||
|
*/
|
||||||
|
exportGallery(): string {
|
||||||
|
return JSON.stringify([...this.gallery.values()], null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Storyboard ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a storyboard (CineFable's bottom panel — drag-reorderable shot list).
|
||||||
|
*/
|
||||||
|
createStoryboard(title: string, shots?: GalleryItem[]): Storyboard {
|
||||||
|
const board: Storyboard = {
|
||||||
|
id: `story_${Date.now()}`,
|
||||||
|
title,
|
||||||
|
shots: shots?.map((s, i) => ({ ...s, sceneNumber: i + 1 })) ?? [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.storyboards.set(board.id, board);
|
||||||
|
return board;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a shot to a storyboard at a specific position.
|
||||||
|
*/
|
||||||
|
addShot(storyboardId: string, item: GalleryItem, position?: number): Storyboard | null {
|
||||||
|
const board = this.storyboards.get(storyboardId);
|
||||||
|
if (!board) return null;
|
||||||
|
|
||||||
|
const shot = { ...item, sceneNumber: position ?? board.shots.length + 1 };
|
||||||
|
if (position !== undefined && position >= 0 && position < board.shots.length) {
|
||||||
|
board.shots.splice(position, 0, shot);
|
||||||
|
} else {
|
||||||
|
board.shots.push(shot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renumber
|
||||||
|
board.shots.forEach((s, i) => { s.sceneNumber = i + 1; });
|
||||||
|
return board;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder shots in a storyboard (CineFable drag-to-reorder).
|
||||||
|
*/
|
||||||
|
reorderShots(storyboardId: string, fromIndex: number, toIndex: number): Storyboard | null {
|
||||||
|
const board = this.storyboards.get(storyboardId);
|
||||||
|
if (!board) return null;
|
||||||
|
|
||||||
|
const [moved] = board.shots.splice(fromIndex, 1);
|
||||||
|
board.shots.splice(toIndex, 0, moved);
|
||||||
|
board.shots.forEach((s, i) => { s.sceneNumber = i + 1; });
|
||||||
|
return board;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all storyboards.
|
||||||
|
*/
|
||||||
|
getStoryboards(): Storyboard[] {
|
||||||
|
return [...this.storyboards.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Capability Check ───────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the media generator has actual API access.
|
||||||
|
*/
|
||||||
|
isAvailable(): boolean {
|
||||||
|
return this.config.engine !== "mock" || !!this.config.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFalAIKey(): boolean {
|
||||||
|
return !!this.config.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async callFalAI(endpoint: string, body: Record<string, unknown>): Promise<any> {
|
||||||
|
const response = await fetch(`${this.config.falBaseUrl}/${endpoint}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Key ${this.config.apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.text().catch(() => "unknown");
|
||||||
|
throw new Error(`fal.ai API error ${response.status}: ${err.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mockResult(
|
||||||
|
type: "image" | "video",
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
startTime: number,
|
||||||
|
): MediaResult {
|
||||||
|
return {
|
||||||
|
url: `mock://${type}/${Date.now()}`,
|
||||||
|
type,
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
error: this.config.apiKey ? null : "No FAL_KEY configured — add to .env for real generation",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue