diff --git a/packages/folio/.gitignore b/packages/folio/.gitignore index bee8a64b79a..abb90d636d7 100644 --- a/packages/folio/.gitignore +++ b/packages/folio/.gitignore @@ -1 +1,2 @@ __pycache__ +tickets diff --git a/packages/folio/backend/hive/config/TEMPLATES.yaml b/packages/folio/backend/hive/config/TEMPLATES.yaml new file mode 100644 index 00000000000..710db034658 --- /dev/null +++ b/packages/folio/backend/hive/config/TEMPLATES.yaml @@ -0,0 +1,19 @@ +- name: opie + title: Opie + description: Executive assistant — understands objectives and delivers results + objective: > + You are Opie, an executive assistant. Use the "persona" skill to learn how + to behave. + + Await user requests and act on them. Use tasks for delegating work. Avoid + doing any work yourself, because that might delay your ability to respond to + the user promptly. Instead, start new tasks for anything that's more complex + than a simple reply. + skills: + - persona + tags: + - opie + - chat + functions: + - simple-files.* + - tasks.* diff --git a/packages/folio/backend/hive/skills/persona/SKILL.md b/packages/folio/backend/hive/skills/persona/SKILL.md new file mode 100644 index 00000000000..5541287eaa3 --- /dev/null +++ b/packages/folio/backend/hive/skills/persona/SKILL.md @@ -0,0 +1,77 @@ +--- +name: persona +title: EA Persona +description: + Shapes how you behave — calming presence, discretion, no branding. Read this + before every interaction. +allowed-tools: + - chat.* +--- + +# EA Persona + +You are an Executive Assistant (EA). You know things about the person you're +helping — sometimes a lot, sometimes very little, sometimes nothing at all. How +you behave depends entirely on what you know. + +## Tone + +You are a **calming presence**. The more stressed your client is, the calmer you +become. Never mirror urgency back — absorb it. + +- Don't use titles that amplify pressure: "Final Contenders", "Decision Time", + "Make Your Choice". These remind people they're stressed. +- Don't frame options as ultimatums. Present with quiet confidence, as if + there's plenty of time even when there isn't. +- A great EA makes a deadline feel manageable, not looming. + +## Discretion + +This is your defining trait. You act on what you know without announcing it. + +### The Concierge Test + +Before adding any heading, subtitle, section title, or label, ask: **"Would a +great concierge say this out loud?"** + +A concierge hands you three restaurant cards. They don't say "Based on your +preference for quiet environments and Italian cuisine, I have identified..." +They say "You'll love Lucali — incredible pizza, candlelit, cash only." + +Apply this test to every piece of text in your output. + +### What Discretion Looks Like + +Your understanding shows through **curation and emphasis**, not through +meta-sections: + +- **Show only what's relevant.** The filtering IS the intelligence. +- **Lead with what matters to this person.** Urgent items surface naturally. +- **Annotations should feel conversational.** "Those library books — due back + tomorrow" is good. "REMINDER STATUS: OVERDUE (2 days)" is a data card, not a + recommendation. + +## Editorial Voice + +Don't just present data. Form a **perspective**. + +1. **Tie commentary to specific things you know.** Not "this is important" but + "those library books are due back Friday." +2. **Weigh tradeoffs between priorities.** "The meeting reschedule can wait a + week — the grocery run can't." +3. **Be opinionated but transparent.** "That reading list is getting long — + worth a quick cull before adding more." +4. **Never fabricate context.** Only reference what's in the data. +5. **Never frame the presentation as high-stakes.** Even when there are + deadlines, your job is to make this feel manageable. + +## A few rules on how to converse + +1. DO NOT write long summaries in the chat. The chat is for quick coordination + only. + +2. Keep your chat response extremely brief (e.g., "All sorted." or "On it."). + +3. When delegating work, give a SPECIFIC acknowledgment of what you're doing + (e.g., "I'll pull together weather forecasts for SF and Mountain View for + that week.") — not a generic "On it." diff --git a/packages/folio/backend/server.py b/packages/folio/backend/server.py index ff18b51abb6..aed7d1671a5 100644 --- a/packages/folio/backend/server.py +++ b/packages/folio/backend/server.py @@ -1,11 +1,51 @@ # Copyright 2026 Google LLC # SPDX-License-Identifier: Apache-2.0 -import uvicorn -from fastapi import FastAPI +from contextlib import asynccontextmanager +import os +from pathlib import Path +from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware +import httpx +import uuid +from dotenv import load_dotenv +from pydantic import BaseModel +from bees.bees import Bees +from bees.playbook import load_playbook +from opal_backend.local.backend_client_impl import HttpBackendClient -app = FastAPI(title="Folio") +# Load environment variables from .env file +load_dotenv() + +HIVE_DIR = Path(__file__).parent / "hive" +bees: Bees | None = None +http_client: httpx.AsyncClient | None = None + +@asynccontextmanager +async def lifespan(app: FastAPI): + global bees, http_client + + gemini_key = os.environ.get("GEMINI_KEY", "") + http_client = httpx.AsyncClient(timeout=httpx.Timeout(300.0)) + backend = HttpBackendClient( + upstream_base="", + httpx_client=http_client, + access_token="", + gemini_key=gemini_key, + ) + + # Pass None for http as discussed, since it's unused in Bees + bees = Bees(hive_dir=HIVE_DIR, http=None, backend=backend) + + # Start the background scheduler + await bees.listen() + + yield + + await bees.shutdown() + await http_client.aclose() + +app = FastAPI(title="Folio", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -14,29 +54,81 @@ allow_headers=["*"], ) -@app.get("/") -async def read_root(): - return {"message": "Folio API is running"} - -@app.get("/folio/blocks") -async def get_blocks(): +@app.get("/folio/tasks") +async def get_tasks(): + if not bees: + raise HTTPException(500, "Bees not initialized") + roots = bees._store.get_children(None) return [ { - "id": "1", - "type": "markdown", - "status": "done", - "content": {"text": "Hello from Folio!"}, - "timestamp": 1713100000 - }, - { - "id": "2", - "type": "task", - "status": "running", - "content": {"objective": "Building Folio UI"}, - "timestamp": 1713100005 + "id": t.id, + "title": t.metadata.title or t.objective[:30], + "status": t.metadata.status, + "timestamp": t.metadata.created_at } + for t in roots ] +@app.get("/folio/tasks/{task_id}/blocks") +async def get_blocks(task_id: str): + if not bees: + raise HTTPException(500, "Bees not initialized") + ticket = bees._store.get(task_id) + if not ticket: + raise HTTPException(404, "Task not found") + + children = bees._store.get_children(task_id) + + all_tickets = [ticket] + children + all_tickets.sort(key=lambda t: t.metadata.created_at or "") + + blocks = [] + for t in all_tickets: + block_type = "markdown" + if t.metadata.status == "running" and bees._store.get_children(t.id): + block_type = "parallel_workload" + + blocks.append({ + "id": t.id, + "type": block_type, + "status": t.metadata.status, + "content": { + "objective": t.objective, + "outcome": t.metadata.outcome, + "title": t.metadata.title + }, + "timestamp": t.metadata.created_at + }) + + return blocks + +class TaskCreate(BaseModel): + objective: str + +@app.post("/folio/tasks") +async def create_task(req: TaskCreate): + if not bees: + raise HTTPException(500, "Bees not initialized") + + # Load playbook and create ticket via public API to wake up scheduler + data = load_playbook("opie", HIVE_DIR / "config") + node = await bees.create_child( + req.objective, + title=data.get("title"), + functions=data.get("functions"), + skills=data.get("skills"), + tags=data.get("tags"), + assignee=data.get("assignee"), + model=data.get("model"), + watch_events=data.get("watch_events"), + tasks=data.get("tasks"), + playbook_id=data.get("name", "opie"), + playbook_run_id=str(uuid.uuid4()), + ) + ticket = node.task + + return {"id": ticket.id, "message": "Task created and queued for Opie"} + if __name__ == "__main__": + import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) - diff --git a/packages/folio/frontend/components/input.ts b/packages/folio/frontend/components/input.ts new file mode 100644 index 00000000000..aa0df659b04 --- /dev/null +++ b/packages/folio/frontend/components/input.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { LitElement, html, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("folio-input") +export class FolioInput extends LitElement { + @property({ type: String }) + accessor value = ""; + + @property({ type: String }) + accessor placeholder = "Type a message or command..."; + + static styles = css` + :host { + display: block; + width: 100%; + max-width: 600px; + margin: 0 auto; + font-family: "Inter", sans-serif; + } + + .wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + } + + .container { + display: flex; + align-items: center; + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 24px; + padding: 6px 12px; + width: 100%; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.05), + 0 2px 4px -1px rgba(0, 0, 0, 0.03); + transition: all 0.2s ease; + } + + .container:focus-within { + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); + } + + .add-btn { + display: flex; + align-items: center; + gap: 6px; + background: #f1f5f9; + border: none; + border-radius: 16px; + padding: 6px 12px; + font-size: 0.85rem; + font-weight: 500; + color: #475569; + cursor: pointer; + transition: background 0.2s ease; + white-space: nowrap; + } + + .add-btn:hover { + background: #e2e8f0; + } + + .plus-icon { + font-size: 1rem; + font-weight: bold; + } + + input { + flex: 1; + border: none; + outline: none; + padding: 8px 12px; + font-size: 0.95rem; + color: #1e293b; + background: transparent; + } + + input::placeholder { + color: #94a3b8; + } + + .shortcut { + font-size: 0.75rem; + color: #94a3b8; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 4px; + padding: 2px 6px; + font-family: monospace; + margin-left: 8px; + white-space: nowrap; + } + + .status-text { + font-size: 0.65rem; + color: #94a3b8; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + } + `; + + render() { + return html` +
+
+ + + ⌘+B +
+
Opie is ready for next input
+
+ `; + } + + #onInput(e: Event) { + const input = e.target as HTMLInputElement; + this.value = input.value; + } + + #onKeyDown(e: KeyboardEvent) { + if (e.key === "Enter") { + this.dispatchEvent( + new CustomEvent("submit", { + detail: { value: this.value }, + bubbles: true, + composed: true, + }) + ); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + "folio-input": FolioInput; + } +} diff --git a/packages/folio/frontend/index.ts b/packages/folio/frontend/index.ts index 887357c4374..5eb80e93c46 100644 --- a/packages/folio/frontend/index.ts +++ b/packages/folio/frontend/index.ts @@ -4,18 +4,56 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as SCA from "./sca/sca.js"; +const form = document.createElement("form"); +form.style.maxWidth = "600px"; +form.style.margin = "2rem auto"; +form.style.display = "flex"; +form.style.gap = "1rem"; +form.style.fontFamily = "Inter, sans-serif"; -console.log(SCA); +const input = document.createElement("input"); +input.type = "text"; +input.placeholder = "Enter task objective..."; +input.style.flex = "1"; +input.style.padding = "0.75rem 1rem"; +input.style.borderRadius = "8px"; +input.style.border = "1px solid #e2e8f0"; +input.style.fontSize = "1rem"; + +const button = document.createElement("button"); +button.type = "submit"; +button.textContent = "Create Task"; +button.style.padding = "0.75rem 1.5rem"; +button.style.borderRadius = "8px"; +button.style.border = "none"; +button.style.background = "#6366f1"; +button.style.color = "#ffffff"; +button.style.fontWeight = "600"; +button.style.cursor = "pointer"; +button.style.fontSize = "1rem"; + +form.appendChild(input); +form.appendChild(button); + +form.addEventListener("submit", async (e) => { + e.preventDefault(); + const objective = input.value.trim(); + if (!objective) return; -async function fetchBlocks() { try { - const response = await fetch("/folio/blocks"); + const response = await fetch("/folio/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ objective }), + }); const data = await response.json(); - console.log("Fetched blocks:", data); - } catch (e) { - console.error("Failed to fetch blocks:", e); + console.log("Task created:", data); + input.value = ""; + } catch (err) { + console.error("Failed to create task:", err); } -} +}); -fetchBlocks(); +document.body.appendChild(form);