diff --git a/.gitignore b/.gitignore index 10af319..32dfb56 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ test-*.ts .claude/ AGENTS.override.md .agents-override-hash +scratch/ diff --git a/config.yaml.example b/config.yaml.example index 5e83723..ffea070 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -5,11 +5,29 @@ telegram: allowed_chats: - "your-chat-id" # Get via: send /start to @userinfobot on Telegram +# Runtimes: prefer `codex` (OpenAI) or `claude-code` (Anthropic) — their model +# traffic flows through the Thronglets gateway, which unlocks tool-call visibility, +# per-task model switching, telemetry-driven dispatch, and gamification. +# `cursor` is DEPRECATED: it runs in Cursor's cloud and bypasses the gateway. agents: - name: default - runtime: cursor - api_key: ${CURSOR_API_KEY} # Get from: https://cursor.com/settings - model: claude-opus-4-6 + runtime: codex + api_key: ${OPENAI_API_KEY} # Get from: https://platform.openai.com/api-keys + model: gpt-4o-mini + + # - name: claude + # runtime: claude-code + # api_key: ${ANTHROPIC_API_KEY} + # model: claude-haiku-4-5-20251001 + + # `native` (Phase F): Thronglets runs the agent loop itself — no vendor SDK. + # Talks to the OpenAI/Anthropic API directly, executes tools in-process, and + # emits telemetry straight to the fleet bus (dispatch + gamification for free). + # Provider is inferred from the model id (claude* → anthropic, else openai). + # - name: nova + # runtime: native + # api_key: ${OPENAI_API_KEY} + # model: gpt-4o-mini # Dispatcher: AI-powered message router that manages the fleet dispatcher: @@ -41,6 +59,42 @@ fleet: # tool_calls: show fleet tool execution logs tool_calls: true + # ─── Gateway-powered dispatch (Phase A–E) ─── + # Per-task model tiers. Dispatch picks small/mid/large per task and the gateway + # rewrites the model on the fly. Override the defaults here if you like: + # models: + # openai: { small: gpt-4o-mini, mid: gpt-4o, large: gpt-4.1 } + # anthropic: { small: claude-haiku-4-5-20251001, mid: claude-sonnet-4-6, large: claude-opus-4-8 } + + # Per-agent USD budget (0 = unlimited). The dispatch engine flags over-budget throngs. + # budget_usd_per_agent: 0 + + # File-ownership lock window (ms). Stops two throngs editing the same file at once. + # lock_ttl_ms: 300000 + +# ─── Token gateway (governance) ─── +# A real LLM gateway (Bifrost-inspired): the gateway holds the upstream provider +# keys, and every agent reaches the model through a *virtual key* (`vk-`) +# — so no throng ever holds an `sk-…`. Per-VK budgets/rate-limits are metered and +# enforced; provider keys load-balance and fail over. Stats at GET /gateway/stats. +# +# When `gateway.enabled: true`, native agents are automatically routed through it. +# Omit the block entirely to keep today's behavior (direct provider calls). +# gateway: +# enabled: true +# providers: +# openai: { keys: ["${OPENAI_API_KEY}"] } # one or more — extra keys = failover +# anthropic: { keys: ["${ANTHROPIC_API_KEY}"] } +# virtual_keys: +# # The dispatcher gets a generous budget and downgrades (not blocks) when spent. +# _dispatcher: { providers: [openai], budget: { usd: 5, window: daily }, on_exceed: downgrade } +# # Default for every other throng: hard daily cap + rate limit. +# "*": { budget: { usd: 2, window: daily }, on_exceed: block, rpm: 60 } +# # budget windows: daily | monthly | total. on_exceed: block | downgrade. + +# Gateway: set THRONGLETS_GATEWAY_ENABLED=false to disable the API proxy entirely +# (falls back to plain SDK calls — no telemetry, dispatch, or gamification). + # Optional: local conversation logs session: log_dir: ~/.thronglets/logs diff --git a/docs/gateway-strategy.md b/docs/gateway-strategy.md new file mode 100644 index 0000000..98c4aa1 --- /dev/null +++ b/docs/gateway-strategy.md @@ -0,0 +1,302 @@ +# Gateway 策划方案 — 采集 · Dispatch · 游戏化 + +> 状态:**Phase A–F 全部实现并各自闭环通过**(详见文末「实现进度」) +> +> 一句话:把 runtime 从「调用厂商 SDK 拿一段文本」改成「坐在模型 API 前面当网关」, +> 从此能看见 agent 干活的**全过程**——这是让 vibe coding 从"一团雾水"变成 +> "清晰可见、好理解、有趣、流畅"的唯一地基。 + +## 实现进度(截至当前分支) + +| 阶段 | 状态 | 关键文件 | 闭环测试 | +|------|:----:|---------|---------| +| **P0** 网关 PoC | ✅ | `src/gateway/proxy.ts` | `test/gateway-openai.ts` | +| **A** 模型三档 + per-task 切换 | ✅ | `gateway/models.ts` `gateway/directives.ts` | `test/gateway-model-switch.ts` | +| **B** 采集脊柱(SSE 流式 + trace) | ✅ | `gateway/sse.ts` `gateway/trace.ts` | `test/gateway-streaming.ts` | +| **C** Dispatch 引擎 | ✅ | `fleet/dispatch-engine.ts` | `test/dispatch-engine.test.ts` | +| **D** 游戏化内核 | ✅ | `fleet/game-state.ts` | `test/game-state.test.ts` | +| **E** Dashboard 时间线 + 游戏视图 | ✅ | `dashboard/components/ActivityTimeline.tsx` | `test/e2e-pipeline.ts` | +| **F** 自研 agent loop(北极星) | ✅ | `runtimes/native/` | `test/native-tools.test.ts` `test/native-loop.test.ts` `test/native-runtime.ts` | + +- **Cursor 已弃用**:`CursorRuntime` 标注 `@deprecated` 并在运行时打警告;默认 runtime 改为 `codex`。 +- 纯逻辑测试(C/D)已纳入 vitest CI;网关测试为独立脚本(需 `OPENAI_API_KEY`,兼作 demo)。 +- 逃生阀:`THRONGLETS_GATEWAY_ENABLED=false` 一键回退到纯 SDK 调用。 + +--- + +## 0. 核心转变:数据源变了 + +旧地基(`src/runtimes/interface.ts`): + +```ts +interface AgentSession { + send(text: string): Promise; // 全部信息量 = 最后一段文本 + close(): void; +} +``` + +系统对 agent 内部发生的一切只能看到**最后吐出来的那句话**。看不到读了什么文件、 +改了哪几行、跑了什么命令、烧了多少 token。你想把一个黑盒游戏化,但数据源只有黑盒的 +最后一句话——这就是"雾"的根因。 + +新地基(网关):让每个 agent 把 `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL` 指向 +本地网关,截获**完整协议流**: + +- 每一次请求里的完整上下文(context window) +- 每一个 `tool_call`(OpenAI)/ `tool_use`(Anthropic)——文件读写、bash、grep,带完整参数 +- 下一次请求里回带的 `role:"tool"` 结果——动作的**结果**(测试过没过、报错内容) +- `usage`:prompt / completion / cached / reasoning tokens → 成本、延迟 +- 错误、限流、拒绝 + +比 `send()->string` 丰富 100 倍。**这是采集、dispatch、游戏化三件事共同的原材料。** + +PoC 已验证(`test/gateway-openai.ts`):OpenAI tool-calling 请求经网关 → 拦截 +2 个 `get_weather` 调用 → 发出 `tool_call` 事件 ✅。 + +--- + +## 1. 取舍:弃用 Cursor + +| Runtime | 模型流量 | 网关可观测 | 决策 | +|---------|---------|:---------:|------| +| **Cursor** | Cursor 自己的云 | ❌ 永远不行(流量不经过本机) | **弃用** | +| **Codex** | OpenAI API | ✅ `OPENAI_BASE_URL` 可配 | 主力(成本优先) | +| **Claude Code** | Anthropic API | ✅ `ANTHROPIC_BASE_URL` 可配 | 备用 / 高难度任务 | +| **Native** (Phase F) | OpenAI / Anthropic API(进程内自跑 loop) | ✅ 遥测直连总线,无需网关 | **北极星**:最彻底的控制 | + +Cursor 在结构上就与"全程可见"的目标冲突——它的整条思维链都在 Cursor 云端,本机没有 +拦截点。要让整条管线自洽(一切可见、可计费、可调度),就必须以可观测的 runtime 为核心。 + +**落地动作:** +- 默认 runtime 改为 `codex`,所有 `defaultModels` 与文档示例切到 codex/claude-code +- `CursorRuntime` 标记 `@deprecated`,README 对比表重写(不再宣传 Cursor primary) +- 不必第一天就删代码,但停止在任何新功能里支持它 + +--- + +## 2. 总体架构 + +``` + ┌──────────────────────────────────────────────────────────┐ + agent ──▶│ GATEWAY (传感器) — 唯一的真相来源 │ + (codex/cc) │ · 透传请求到 OpenAI/Anthropic │ + │ · 解析 tool_call / tool_result / usage / error │ + │ · 归一化成 ThrongTrace 事件 │ + └───────────────┬──────────────────────────────────────────┘ + │ ThrongTrace events (bus.publish) + ┌───────────────────┼───────────────────┬───────────────────┐ + ▼ ▼ ▼ ▼ + ┌───────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ 持久化 │ │ 指标引擎 │ │ Dispatch 引擎 │ │ 游戏状态 │ + │ trace.jsonl│ │ tokens/cost/ │ │ 文件锁/预算/ │ │ XP/stats/mood│ + │ │ │ 延迟/测试结果 │ │ 负载/能力路由 │ │ │ + └───────────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ + └────────────────────┴──────────────────┘ + │ WebSocket (现有 ws.ts) + ▼ + ┌──────────────────────────────────┐ + │ DASHBOARD │ + │ · 实时活动时间线(散雾) │ + │ · RTS 代码库地图(拟物) │ + │ · 任务/quest 卡片 · 成本仪表 │ + └──────────────────────────────────┘ +``` + +网关是整个系统的**单一传感器**。现有的 `FleetEventBus.publish()` → +`ws.ts` 已经把所有事件广播给前端,所以接入成本很低。 + +--- + +## 3. Layer 1 — 采集(Telemetry Spine) + +目标:把网关从"打印 tool_call"升级成一条**机器可读、可回放、可统计**的事件流。 + +### 3.1 统一事件模型 ThrongTrace + +把 Anthropic 与 OpenAI 两种格式归一化成一种内部事件: + +```ts +type ThrongTraceKind = + | "request" // 一次模型调用开始(带 context 摘要) + | "model_text" // 模型产出的自然语言 + | "tool_call" // 模型决定调用工具(name + input) + | "tool_result" // 工具执行结果(来自下一次请求的回带) + | "usage" // token / 成本 / 延迟 + | "error"; // 报错 / 限流 / 拒绝 + +interface ThrongTrace { + agent: string; + session: string; + ts: string; + kind: ThrongTraceKind; + provider: "openai" | "anthropic"; + // kind-specific payload + tool?: { id: string; name: string; input: Record; summary: string }; + result?: { toolId: string; ok: boolean; preview: string }; + usage?: { inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; latencyMs: number }; + error?: { type: string; message: string }; +} +``` + +落地:`src/gateway/proxy.ts` 里两个 provider 的解析器都产出 `ThrongTrace`, +统一经 `bus.publish("tool_call" | "tool_result" | "usage" | "error", ...)` 发出。 +`types.ts` 的 `FleetEventType` 已含 `tool_call` / `tool_result`,仅需补 `usage`。 + +### 3.2 必须解决的三个技术点(按优先级) + +**① SSE 流式透传(最高优先级 / 当前 PoC 缺口)** +当前网关用 `await upstream.json()`——**只对非流式请求有效**。真实 agent(Codex/ +Claude Code SDK)几乎都用 `stream: true`,响应是 SSE。必须改成: +- 透传 `text/event-stream`,逐 chunk 转发给 agent(不破坏体验) +- 同时旁路解析 delta,拼出 `tool_calls`(OpenAI 的 function arguments 是分片拼接的) +- 这是 PoC → 生产的第一道关,没有它网关对真实 agent 不可用 + +**② Marker 不污染上下文** +现在用首条消息里的 `[GATEWAY_AGENT:name|session]` 标识 agent——会进模型上下文。 +改进:网关读到 marker 后**在转发上游前删掉它**,模型永远看不到。干净、零副作用。 + +**③ tool_result 关联** +解析进来的请求体里 `role:"tool"`(OpenAI)/ `tool_result` block(Anthropic), +按 `tool_call_id` 与之前的 `tool_call` 配对,得到"动作 → 结果"完整时间线。 +对 `bash` 结果做轻量解析(如 `npm test` 退出码、报错关键字)→ 喂给指标与游戏化。 + +### 3.3 持久化与派生指标 + +- 持久化:每个 agent/session 追加 `~/.thronglets/fleet/traces/{agent}/{session}.jsonl` + (与现有 sessions 目录平行),成为可回放的"录像"。 +- 实时派生:tokens 累计、$ 成本、平均延迟、工具调用次数、触碰文件集合、命令列表、 + 错误率、测试通过率。这些是 dispatch 与游戏化的输入。 + +--- + +## 4. Layer 2 — Dispatch(从"问 LLM"到"策略引擎") + +现状(`src/fleet/dispatcher.ts` + `tools.ts`):dispatcher 是个 LLM agent,读 +`fleet_status` 文本然后用自然语言决定派给谁。有了遥测,可以加一层**结构化决策**, +让 LLM dispatcher 调用,或在网关里直接当护栏运行。 + +### 4.1 网关解锁的调度策略 + +| 策略 | 依赖的遥测 | 网关能做的动作 | +|------|-----------|---------------| +| **成本感知路由** | 每 agent 累计 $ | 贵活给强模型、杂活给便宜模型;超预算时网关**直接拦请求**返回合成错误 | +| **文件归属防撞车** ⭐ | tool_call 里的文件路径 | 维护实时"文件锁地图";A 正在改 `auth.ts` 时,B 对它的写入被网关**拦截/告警** → 协议级防 merge 冲突 | +| **负载/健康路由** | tool_call 速率 | 区分"真在干活"vs"状态卡 working";把任务派给真空闲的 throng | +| **能力/专精路由** | 按任务类型的历史成功率 | throng 形成"技能",对口任务优先 | +| **难度升级** | 错误率 / 反复 thrashing | 检测到一个 throng 在原地打转 → 通知 dispatcher 换更强模型重派 | + +⭐ **文件归属防撞车是杀手锏**:多 agent 协作最大的痛是同时改一个文件导致冲突, +网关在协议层就能阻止,这是 SDK 集成永远做不到的。 + +### 4.2 工程形态 + +- 新模块 `src/fleet/dispatch-engine.ts`:消费 ThrongTrace 流,维护文件锁地图、 + 预算账本、每 agent 能力画像;暴露 `suggestRoute(task)` 与 `checkWrite(agent, file)`。 +- LLM dispatcher 通过新工具 `fleet_route_suggest` 咨询它(保留 LLM 的灵活性)。 +- 硬护栏(预算、文件锁)直接在网关 `handle()` 里执行,不依赖 LLM 守规矩。 + +--- + +## 5. Layer 3 — 游戏化(真信号驱动) + +Roadmap 早就想要"creature mood 反映真实表现 ... 成为 reward loop 的一部分" +(README:358-359)。过去做不到是因为没有真信号——网关把信号补齐了。 +PixelThronglet 已有 working/waiting/sleeping/dead 的情绪动画,现在喂真实状态即可。 + +### 5.1 情绪 = 真实状态(不再纯装饰) + +| Mood | 触发信号(来自遥测) | +|------|---------------------| +| 🧠 thinking | 模型延迟高、还没发出 tool_call | +| ⚙️ working | tool_call 高频,正在读写跑 | +| 😖 stuck | 连续 tool_result 报错 / 反复改同一文件无进展 | +| 🎉 triumphant | 刚检测到 `npm test` 通过 / 任务完成 | +| 😴 exhausted | 单任务 token 烧穿阈值 | +| 💀 dead | 会话永久失败 | + +### 5.2 成长系统 + +- **XP**:来自真实事件——测试通过(+大)、文件交付、低于预算完成、修复 bug。 + 全部由网关观测到的 tool_result 推导(如 bash 退出码 0)。 +- **属性**:每 throng 累积 Speed(延迟) / Efficiency(token/任务) / + Reliability(错误率) / Specialization(最常碰的工具与目录)。 +- **奖励回路(human-in-loop)**:Roadmap 的"pet your throng"——用户在 Telegram / + dashboard 对结果 👍/👎,记入该 throng 的信任分,可反哺路由(用户信任的 throng 优先派活)。 + +### 5.3 头牌体验:RTS 代码库地图 + +把代码库渲染成游戏世界(文件/目录 = 地块)。throng 的动作肉眼可见: + +- 读文件 → creature 走到该文件去"查看" +- 改文件 → 在该文件上"施工" +- 跑测试 → 一个可见的"动作",带成功/失败结果反馈 +- 两个 throng 想碰同一文件 → 视觉上的"争用"提示(呼应 4.1 文件锁) + +这就是"清晰可见、有趣、流畅"的兑现点——vibe coding 从"发消息后干等"变成 +"看着我的单位在代码库地图上移动、施工、跑测试、升级",**可观战 + 可指挥**。 + +### 5.4 任务管理器 = Quest 系统 + +把 task manager 框架成 quest:一个任务 = 一张 quest 卡(目标、指派的 throng、 +实时进度=工具活动+测试状态推导、完成判据)。现有 `taskLedger` +(`manager.ts:120`)已是雏形,升级为带实时进度的 quest 即可。 + +--- + +## 6. 分阶段路线图 + +每个阶段都可独立交付 + 有一个可演示的"爽点"。 + +| 阶段 | 交付物 | Demo 爽点 | +|------|--------|----------| +| **P0 ✅ 已完成** | 网关 PoC,双协议 tool_call 拦截 | `test/gateway-openai.ts` 跑通 | +| **P1 采集脊柱** | SSE 流式透传 · marker 不污染 · tool_result 配对 · ThrongTrace 持久化 · usage 事件 | 一条完整机器可读的活动流 | +| **P2 活动时间线** ⭐ | Dashboard 实时逐 throng 动作流(📖✏️▶️🔍 + 结果)+ token/成本仪表 | **第一次"看见" agent 在想什么、做什么——散雾** | +| **P3 Dispatch 引擎** | 文件锁防撞车 · 成本预算硬护栏 · 负载/健康路由 | 多 agent 协作不再撞文件;超预算自动拦 | +| **P4 游戏化内核** | XP/属性/真实情绪 · 奖励反应 | 你会真的为一只 throng 升级而开心,为它 stuck 而心疼 | +| **P5 RTS 地图** ⭐ | 代码库即世界的实时观战视图 · quest 卡 | 头牌体验,截图/视频即传播素材 | +| **P6 北极星** ✅ | 自研 agent loop(`runtime: native`,不依赖厂商 SDK,进程内直接跑 tool 循环) | 更彻底的控制:会话中途换模型、协议级注入工具、最多调度策略 | + +P1 + P2 是"一鸣惊人"的最短路径——先把雾散掉。 + +### Phase F 落地说明(自研 loop) + +`runtime: native` 选中 `src/runtimes/native/`。与网关路线的关键区别: + +- **进程内自跑循环**:`AgentLoop.run()` 直接 `调用模型 → 解析 tool_call → 本地执行 → 回灌结果 → 再循环`, + 直到模型给出最终文本。不再经过 codex-sdk / claude-agent-sdk。 +- **遥测直连总线**:因为 loop 在我们手里,`tool_call/tool_result/usage/model_switch` 事件**直接 publish** 到 + `FleetEventBus`——无需 marker、无需 SSE 重组。Dispatch + 游戏化照常订阅,native throng 直接在 Dashboard 点亮。 +- **真·任务中途换模型**:模型在**每一步**前读 `directiveStore.consumeTier()`,可在两次 tool 调用之间 small→large。 +- **双 provider**:`agent-loop.ts` 用 adapter 抽象 OpenAI(chat completions)与 Anthropic(messages), + 按 model id 自动判定(`claude*` → anthropic)。 +- **工具集**:`read_file / write_file / edit_file / list_dir / grep / run_bash`,在 workspace 内本地执行。 + +闭环测试:`test/native-tools.test.ts`(执行器)+ `test/native-loop.test.ts`(脚本化 transport 跑通整圈循环、 +模型切换、双 provider 适配)+ `test/native-runtime.ts`(真实 OpenAI 流量端到端)。 + +--- + +## 7. 关键风险与对策 + +| 风险 | 说明 | 对策 | +|------|------|------| +| **SSE 流式(最大)** | PoC 只支持非流式;真实 agent 都流式 | P1 第一优先级,先做流式透传 + delta 拼接 | +| **网关持有密钥** | 网关代理所有模型流量,是高价值目标 | 只绑 `127.0.0.1`(现状如此);密钥仅驻内存;trace 落盘脱敏 | +| **per-agent 关联** | Codex SDK 全进程共享 `OPENAI_BASE_URL` | marker 方案够用(P1 改为转发前剥离);北极星阶段自研 loop 可改用独立路由 | +| **成本失控** | 多 agent + 强模型烧钱快 | P3 预算硬护栏;默认 gpt-4o-mini / haiku | +| **厂商 SDK 易碎** | Codex/CC SDK 升级可能变协议 | 网关只依赖 wire protocol,比 SDK 集成更稳;北极星阶段彻底摆脱 SDK | + +--- + +## 附录 A — 当前代码接入点 + +- `src/gateway/proxy.ts` — 网关本体(已有 Anthropic + OpenAI 双解析器) +- `src/runtimes/codex.ts` — 已设 `OPENAI_BASE_URL` 指向 `/gateway/openai` +- `src/runtimes/claude-code.ts` — 已设 `ANTHROPIC_BASE_URL` 指向 `/gateway` +- `src/server/index.ts` — 已挂载两个网关路由 +- `src/fleet/manager.ts` — `FleetEventBus.publish` / `taskLedger` / `getStatus` +- `src/server/ws.ts` — 事件已自动广播给前端 +- `packages/dashboard/src/components/PixelThronglet.tsx` — 情绪动画载体 +- `THRONGLETS_GATEWAY_ENABLED=false` — 一键关闭网关的逃生阀 diff --git a/packages/dashboard/package-lock.json b/packages/dashboard/package-lock.json index 72f4402..35e739f 100644 --- a/packages/dashboard/package-lock.json +++ b/packages/dashboard/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@kenyalang/dashboard", + "name": "@thronglets/dashboard", "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@kenyalang/dashboard", + "name": "@thronglets/dashboard", "version": "0.6.0", "dependencies": { "react": "^19.0.0", diff --git a/packages/dashboard/public/chill/index.html b/packages/dashboard/public/chill/index.html index 470388b..74469b7 100644 --- a/packages/dashboard/public/chill/index.html +++ b/packages/dashboard/public/chill/index.html @@ -43,6 +43,8 @@ 0 🍖 0 💬 0 + 🎁 0 + ☀️ day @@ -806,7 +808,11 @@ function act(x, y) { if (tool === 'spawn') { const b = new Bot(x, y); bots.push(b); toast(`🥚 ${b.name} hatched!`, '#b0e0a0'); return; } - if (tool === 'inspect') { const b = getBot(x, y); if (b) showTip(x, y, b); return; } + if (tool === 'inspect') { + const l = getLoot(x, y); if (l) { showLootTip(x, y, l); return; } + const b = getBot(x, y); if (b) showTip(x, y, b); return; + } + const lh = getLoot(x, y); if (lh) { showLootTip(x, y, lh); lh.pulse = 1; emits(lh.x, lh.y - 8, '✨'); return; } const b = getBot(x, y); if (!b) return; if (tool === 'feed') b.feed(); else if (tool === 'pet') b.pet(); @@ -824,7 +830,7 @@ cv.onmousedown = e => { md = true; act(e.clientX, e.clientY); }; cv.onmouseup = () => md = false; -cv.onmousemove = e => { mx = e.clientX; my = e.clientY; if (md && tool === 'pet') { const b = getBot(mx, my); if (b && b.mood !== 1) b.pet(); } cv.style.cursor = getBot(mx, my) ? (tool === 'inspect' ? 'help' : 'pointer') : 'crosshair'; }; +cv.onmousemove = e => { mx = e.clientX; my = e.clientY; if (md && tool === 'pet') { const b = getBot(mx, my); if (b && b.mood !== 1) b.pet(); } cv.style.cursor = (getBot(mx, my) || getLoot(mx, my)) ? (tool === 'inspect' ? 'help' : 'pointer') : 'crosshair'; }; cv.oncontextmenu = e => { e.preventDefault(); const old = tool; tool = 'poke'; act(e.clientX, e.clientY); tool = old; }; cv.ontouchstart = e => { e.preventDefault(); const t = e.touches[0]; mx = t.clientX; my = t.clientY; act(mx, my); }; cv.ontouchmove = e => { const t = e.touches[0]; mx = t.clientX; my = t.clientY; }; @@ -859,6 +865,148 @@ document.getElementById('time-indicator').firstChild.textContent = todIcon + ' '; } +// ============================================================ +// ARTIFACT LOOT — files-as-loot, dropped into the habitat world. +// The parent dashboard posts the atlas (/api/atlas) in; each artifact +// becomes a collectible relic on the ground, ranked by how widely it's +// used across sessions. This is the gamification *inside* the world, +// not a separate modal. +// ============================================================ +const RARITY_COLOR = { common:'#9ca3af', uncommon:'#22c55e', rare:'#3b82f6', epic:'#a855f7', legendary:'#f5b942' }; +const RARITY_RANK = { common:0, uncommon:1, rare:2, epic:3, legendary:4 }; +const CLASS_GLYPH = { tome:'📖', rune:'⚙️', crystal:'💎', tool:'🛠️', relic:'🗿' }; +const MAX_LOOT = 42; // keep the meadow readable + +let loot = []; +const lootPos = new Map(); // id -> {x,y} stable placement across refreshes + +function lootBasename(id) { const p = String(id).split('/'); return p[p.length - 1]; } + +function placeLoot(id) { + if (lootPos.has(id)) return lootPos.get(id); + // deterministic-ish scatter inside the fenced meadow, away from the border + const h = hash32(id); + const m = 96; + const x = m + (h % 1000) / 1000 * (W - m * 2); + const y = m + ((h >>> 10) % 1000) / 1000 * (H - m * 2); + const pos = { x, y }; + lootPos.set(id, pos); + return pos; +} + +function ingestAtlas(items) { + if (!Array.isArray(items)) return; + // strongest relics first, capped so the world stays legible + const top = [...items] + .sort((a, b) => (RARITY_RANK[b.rarity] - RARITY_RANK[a.rarity]) || (b.level - a.level)) + .slice(0, MAX_LOOT); + loot = top.map((it) => { + const pos = placeLoot(it.id); + return { + id: it.id, + name: lootBasename(it.id), + path: it.path || it.id, + rarity: it.rarity || 'common', + klass: it.klass || 'relic', + level: it.level || 1, + sessions: it.sessionCount || 0, + discoverers: (it.discoverers || []).length, + by: it.firstDiscoveredBy || '?', + live: !!it.live, + x: pos.x, y: pos.y, + phase: (hash32(it.id) % 628) / 100, + pulse: 0, + }; + }); + const legendary = loot.filter((l) => l.rarity === 'legendary').length; + document.getElementById('lt').textContent = loot.length; + const lgStat = document.getElementById('legendary-stat'); + if (legendary > 0) { lgStat.style.display = ''; document.getElementById('lg').textContent = legendary; } + else { lgStat.style.display = 'none'; } +} + +function drawLoot(dt) { + for (const l of loot) { + l.phase += dt * 2; + const col = RARITY_COLOR[l.rarity] || '#9ca3af'; + const rank = RARITY_RANK[l.rarity] || 0; + const float = rank >= 3 ? Math.sin(l.phase) * 3 : Math.sin(l.phase * 0.5) * 1; + const gx = l.x, gy = l.y + float; + + // ground shadow + ctx.fillStyle = 'rgba(0,0,0,.18)'; + ctx.beginPath(); ctx.ellipse(l.x, l.y + 13, 9, 3, 0, 0, Math.PI * 2); ctx.fill(); + + // rarity aura — stronger for epic/legendary, throb when freshly touched + const glow = (rank >= 3 ? 0.45 : 0.22) + (l.pulse > 0 ? 0.4 * l.pulse : 0) + (rank >= 3 ? Math.sin(l.phase) * 0.08 : 0); + const grad = ctx.createRadialGradient(gx, gy, 1, gx, gy, 22); + grad.addColorStop(0, col + Math.round(Math.max(0, Math.min(1, glow)) * 255).toString(16).padStart(2, '0')); + grad.addColorStop(1, col + '00'); + ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(gx, gy, 22, 0, Math.PI * 2); ctx.fill(); + + // gem pedestal — a small diamond plate + ctx.save(); + ctx.translate(gx, gy); + ctx.fillStyle = col; + ctx.globalAlpha = 0.9; + ctx.beginPath(); + ctx.moveTo(0, -8); ctx.lineTo(9, 0); ctx.lineTo(0, 8); ctx.lineTo(-9, 0); ctx.closePath(); + ctx.fill(); + ctx.globalAlpha = 1; + ctx.strokeStyle = 'rgba(255,255,255,.5)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.restore(); + + // class glyph + ctx.font = '13px serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(CLASS_GLYPH[l.klass] || '🗿', gx, gy - 0.5); + ctx.textBaseline = 'alphabetic'; + + // level badge for the notable relics + if (rank >= 2) { + ctx.font = 'bold 8px monospace'; ctx.textAlign = 'center'; + ctx.fillStyle = col; + roundRect(ctx, gx + 5, gy - 13, 16, 9, 2); ctx.fill(); + ctx.fillStyle = '#0b0b0d'; + ctx.fillText('L' + l.level, gx + 13, gy - 6); + } + + if (l.pulse > 0) l.pulse = Math.max(0, l.pulse - dt * 1.4); + } +} + +function getLoot(x, y) { + for (let i = loot.length - 1; i >= 0; i--) { + if (Math.hypot(x - loot[i].x, y - loot[i].y) < 16) return loot[i]; + } + return null; +} + +function showLootTip(x, y, l) { + tip.style.display = 'block'; + tip.style.left = Math.min(x + 15, W - 220) + 'px'; + tip.style.top = Math.min(y - 50, H - 90) + 'px'; + const col = RARITY_COLOR[l.rarity]; + tip.innerHTML = `${CLASS_GLYPH[l.klass] || ''} ${l.name} L${l.level}
` + + `${l.rarity} · ${l.klass}
` + + `🧩 ${l.sessions} sessions · 👾 ${l.discoverers} throngs
⛏ first found by ${l.by}`; + clearTimeout(tip._t); + tip._t = setTimeout(() => tip.style.display = 'none', 3500); +} + +// Working throngs "discover" nearby relics — a little sparkle of life. +function lootProximity() { + for (const b of bots) { + if (b.status !== 'working') continue; + for (const l of loot) { + if (l.pulse > 0.2) continue; + if (Math.hypot(b.x - l.x, b.y - l.y) < 26) { + l.pulse = 1; + emits(l.x, l.y - 8, l.rarity === 'legendary' ? '✨' : '·'); + } + } + } +} + // --- Game loop --- let last = performance.now(); function loop() { @@ -881,6 +1029,8 @@ updateToasts(dt); drawConnections(); drawButterflies(); + drawLoot(dt); + lootProximity(); bots.sort((a, b) => a.y - b.y); for (const b of bots) { b.update(dt); b.draw(); } drawPollen(); @@ -905,7 +1055,9 @@ // --- Listen for external notifications (from parent dashboard) --- window.addEventListener('message', (evt) => { - if (!evt.data || evt.data.type !== 'thronglet_notification') return; + if (!evt.data) return; + if (evt.data.type === 'thronglet_atlas') { ingestAtlas(evt.data.items); return; } + if (evt.data.type !== 'thronglet_notification') return; const { agentName } = evt.data; const bot = bots.find(b => b.name === agentName); if (bot) { diff --git a/packages/dashboard/src/App.tsx b/packages/dashboard/src/App.tsx index b874828..724be41 100644 --- a/packages/dashboard/src/App.tsx +++ b/packages/dashboard/src/App.tsx @@ -7,7 +7,9 @@ import { MobileDispatcher } from "./components/MobileDispatcher"; import { ChatBar } from "./components/ChatBar"; import { CommandBar } from "./components/CommandBar"; import { SpawnDialog } from "./components/SpawnDialog"; +import { Atlas } from "./components/Atlas"; import { ChillMode } from "./components/ChillMode"; +import { ActivityTimeline } from "./components/ActivityTimeline"; import { useKeyboard } from "./lib/useKeyboard"; const mobileQuery = typeof window !== "undefined" ? window.matchMedia("(max-width: 768px)") : null; @@ -67,8 +69,10 @@ export function App() { )} {isMobile && } + {!isMobile && mode === "work" && } + ); } diff --git a/packages/dashboard/src/components/ActivityTimeline.tsx b/packages/dashboard/src/components/ActivityTimeline.tsx new file mode 100644 index 0000000..1e3491e --- /dev/null +++ b/packages/dashboard/src/components/ActivityTimeline.tsx @@ -0,0 +1,111 @@ +import { useEffect, useRef } from "react"; +import { useFleetStore, fetchGame, getAgentAccent, type GameStats, type AgentState } from "../stores/fleet"; + +const MOOD_EMOJI: Record = { + idle: "😴", + thinking: "🧠", + working: "⚙️", + stuck: "😖", + triumphant: "🎉", + exhausted: "🥵", +}; + +/** + * Telemetry can arrive keyed by a session label like "fleet-_dispatcher-s-…". + * Resolve it back to the throng's friendly name so the feed reads like + * "Orix read manager.ts" instead of a wall of session ids. + */ +function friendlyAgent(raw: string, agents: AgentState[]): string { + let a = agents.find((x) => x.name === raw); + if (!a) a = agents.find((x) => raw.includes(x.name)); + const base = a + ? a.name + : raw.replace(/^(fleet|ext|native)-/, "").replace(/-s-\d.*$/, "").replace(/-[0-9a-z]{5,}$/, ""); + return base === "_dispatcher" ? "Orix" : base; +} + +/** + * The fog-clearing panel: a live feed of what every throng is actually doing + * (reads, edits, bash, tokens, model switches) plus per-throng game state + * (level / XP / mood) — all derived from the gateway telemetry stream. + */ +export function ActivityTimeline() { + const activity = useFleetStore((s) => s.activity); + const gameStats = useFleetStore((s) => s.gameStats); + const agents = useFleetStore((s) => s.agents); + const open = useFleetStore((s) => s.activityOpen); + const toggle = useFleetStore((s) => s.toggleActivity); + const feedRef = useRef(null); + + // Initial + periodic game-state fetch + useEffect(() => { + fetchGame(); + const t = setInterval(fetchGame, 15000); + return () => clearInterval(t); + }, []); + + // Auto-scroll to newest + useEffect(() => { + if (feedRef.current) feedRef.current.scrollTop = feedRef.current.scrollHeight; + }, [activity.length]); + + const accentFor = (raw: string): string => { + let a = agents.find((x) => x.name === raw); + if (!a) a = agents.find((x) => raw.includes(x.name)); + return a ? getAgentAccent(a) : "#888"; + }; + + const statsList = Object.entries(gameStats).filter(([n]) => n !== "_dispatcher"); + + // Keep the feed to events a human can read at a glance: what each throng + // touched (tool calls), model switches, and anything that failed. Token/cost + // ticks and "✓ ok" acknowledgements are dropped — cost already lives in the + // per-throng badges above. + const feed = activity.filter( + (it) => it.kind === "tool_call" || it.kind === "model_switch" || it.ok === false, + ); + + if (!open) { + return ( + + ); + } + + return ( +
+
+ ⚡ Live Activity + +
+ + {statsList.length > 0 && ( +
+ {statsList.map(([name, st]) => ( +
+ {MOOD_EMOJI[st.mood]} + {name} + L{st.level} + {st.xp}xp · {st.specialty} · ${st.costUsd.toFixed(3)} + {st.testsPassed > 0 && ✅{st.testsPassed}} +
+ ))} +
+ )} + +
+ {feed.length === 0 && ( +
Waiting for throng activity…
which files each throng reads, edits & runs streams here
+ )} + {feed.map((item) => ( +
+ {friendlyAgent(item.agent, agents)} + {item.summary} + {new Date(item.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} +
+ ))} +
+
+ ); +} diff --git a/packages/dashboard/src/components/Atlas.tsx b/packages/dashboard/src/components/Atlas.tsx new file mode 100644 index 0000000..143b77a --- /dev/null +++ b/packages/dashboard/src/components/Atlas.tsx @@ -0,0 +1,150 @@ +import { useEffect } from "react"; +import { useFleetStore, fetchAtlas, type AtlasItem, type Rarity, type ArtifactClass } from "../stores/fleet"; + +const RARITY_COLOR: Record = { + common: "#9ca3af", + uncommon: "#22c55e", + rare: "#3b82f6", + epic: "#a855f7", + legendary: "#f59e0b", +}; + +const RARITY_LABEL: Record = { + common: "Common", uncommon: "Uncommon", rare: "Rare", epic: "Epic", legendary: "Legendary", +}; + +const CLASS_GLYPH: Record = { + tome: "📖", rune: "⚙️", crystal: "💎", tool: "🛠️", relic: "🗿", +}; + +const CLASS_LABEL: Record = { + tome: "Tome", rune: "Rune", crystal: "Crystal", tool: "Tool", relic: "Relic", +}; + +function basename(id: string): string { + const parts = id.split("/"); + return parts[parts.length - 1]; +} + +function InvolvementBar({ item }: { item: AtlasItem }) { + const total = item.read + item.edit + item.create + item.search || 1; + const segs: Array<[string, number, string]> = [ + ["read", item.read, "#60a5fa"], + ["edit", item.edit, "#fbbf24"], + ["create", item.create, "#34d399"], + ["search", item.search, "#c084fc"], + ]; + return ( +
+ {segs.map(([k, v, c]) => v > 0 ? ( +
+ ) : null)} +
+ ); +} + +function LootCard({ item }: { item: AtlasItem }) { + const color = RARITY_COLOR[item.rarity]; + return ( +
+
+
+ {CLASS_GLYPH[item.klass]} + {item.level} +
+
+
{basename(item.id)}
+
+ {RARITY_LABEL[item.rarity]} + · {CLASS_LABEL[item.klass]} +
+
+
+
+ +
+
+ 🧩 {item.sessionCount} + 👾 {item.discoverers.length} + ⛏ {item.firstDiscoveredBy} +
+
+ ); +} + +export function Atlas() { + const { atlasOpen, toggleAtlas, atlas, atlasSummary, atlasWorkspaces, currentWorkspace, setWorkspace } = useFleetStore(); + + // refetch when opened or workspace changes + useEffect(() => { + if (atlasOpen) fetchAtlas(currentWorkspace); + }, [atlasOpen, currentWorkspace]); + + useEffect(() => { + if (!atlasOpen) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") toggleAtlas(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [atlasOpen]); + + if (!atlasOpen) return null; + + const totals = Object.values(atlasSummary).reduce( + (acc, s) => ({ artifacts: acc.artifacts + s.artifacts, sessions: acc.sessions + s.sessions, legendary: acc.legendary + s.legendary, live: acc.live + s.live }), + { artifacts: 0, sessions: 0, legendary: 0, live: 0 }, + ); + + return ( +
+
e.stopPropagation()}> +
+
🗺️ Artifact Atlas
+
+ {totals.artifacts} relics + · + {totals.sessions} quests + · + {totals.legendary} legendary + {totals.live > 0 && · {totals.live} live} +
+ +
+ +
+ + {atlasWorkspaces.filter((w) => w !== "unknown").map((w) => ( + + ))} +
+ + {atlas.length === 0 ? ( +
+ No relics discovered yet. As throngs work, the files they touch become loot — + ranked by how widely they're used across sessions. +
+ ) : ( +
+ {atlas.map((item) => )} +
+ )} +
+
+ ); +} diff --git a/packages/dashboard/src/components/CardMenu.tsx b/packages/dashboard/src/components/CardMenu.tsx index 3a8c2f5..ff1a4da 100644 --- a/packages/dashboard/src/components/CardMenu.tsx +++ b/packages/dashboard/src/components/CardMenu.tsx @@ -48,11 +48,17 @@ export function CardMenu({ agent, x, y, accent, onClose }: Props) { }; const isDispatcher = agent.name === "_dispatcher"; - const models = RUNTIME_MODELS[agent.runtime] || []; + const presetModels = RUNTIME_MODELS[agent.runtime] || []; + // Always surface the model the agent is actually on, even if it's not a preset + // (e.g. a dated variant or one set directly in config). + const modelOptions = presetModels.includes(agent.model) + ? presetModels + : [agent.model, ...presetModels]; return (
e.stopPropagation()}> - {/* Runtime / Model section */} + {/* Runtime — not for the dispatcher: only the native runtime has an API + key configured, so switching it would break the orchestrator. */} {!isDispatcher && ( <>
Runtime
@@ -75,32 +81,33 @@ export function CardMenu({ agent, x, y, accent, onClose }: Props) { ))}
)} - -
Model
- - {showModelPicker && ( -
- {models.map((m) => ( - - ))} -
- )} - -
)} + {/* Model — available for every agent, including the dispatcher. */} +
Model
+ + {showModelPicker && ( +
+ {modelOptions.map((m) => ( + + ))} +
+ )} + +
+
Accent color
{PALETTE.map((c) => ( diff --git a/packages/dashboard/src/components/ChillMode.tsx b/packages/dashboard/src/components/ChillMode.tsx index 9f92795..842f06a 100644 --- a/packages/dashboard/src/components/ChillMode.tsx +++ b/packages/dashboard/src/components/ChillMode.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef } from "react"; -import { useFleetStore } from "../stores/fleet"; +import { useFleetStore, fetchAtlas } from "../stores/fleet"; export function ChillMode() { - const { chillNotifications, dismissChillNotification, setMode, selectAgent, setActiveAgent } = useFleetStore(); + const { chillNotifications, dismissChillNotification, setMode, selectAgent, setActiveAgent, atlas } = useFleetStore(); const iframeRef = useRef(null); + const readyRef = useRef(false); useEffect(() => { const timers: number[] = []; @@ -29,6 +30,20 @@ export function ChillMode() { } }, [chillNotifications]); + // Keep the habitat fed with discovered artifacts so loot appears in the world. + useEffect(() => { + fetchAtlas("all"); + const t = window.setInterval(() => fetchAtlas("all"), 15000); + return () => clearInterval(t); + }, []); + + // Push the atlas into the iframe whenever it changes (and once it's ready). + const postAtlas = () => { + if (!readyRef.current || !iframeRef.current?.contentWindow) return; + iframeRef.current.contentWindow.postMessage({ type: "thronglet_atlas", items: atlas }, "*"); + }; + useEffect(postAtlas, [atlas]); + const handleNotificationClick = (agentName: string) => { selectAgent(agentName); setActiveAgent(agentName); @@ -42,6 +57,7 @@ export function ChillMode() { className="chill-iframe" src="/chill/index.html" title="Thronglets Habitat" + onLoad={() => { readyRef.current = true; postAtlas(); }} />
{chillNotifications.slice(-3).map((n) => ( diff --git a/packages/dashboard/src/components/TopBar.tsx b/packages/dashboard/src/components/TopBar.tsx index 21e8d10..26f84a7 100644 --- a/packages/dashboard/src/components/TopBar.tsx +++ b/packages/dashboard/src/components/TopBar.tsx @@ -5,7 +5,7 @@ import { PixelThronglet } from "./PixelThronglet"; import { generateThronglet } from "../lib/thronglet"; export function TopBar() { - const { agents, workspaces, currentWorkspace, setWorkspace, theme, setTheme, toggleDispatcher, mode, setMode } = useFleetStore(); + const { agents, workspaces, currentWorkspace, setWorkspace, theme, setTheme, toggleDispatcher, toggleAtlas, mode, setMode } = useFleetStore(); const [confirmDelete, setConfirmDelete] = useState(null); const [deleteError, setDeleteError] = useState(""); const [editingWs, setEditingWs] = useState(null); @@ -132,6 +132,13 @@ export function TopBar() {
+