diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e1d5a..00627b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ ## [Unreleased] +## [2.6.0] - 2026-04-15 — Decomposition pipeline workbench + +### Added +- `cog decompose` CLI command with 4-tier foveated decomposition via E4B: + Tier 0 (one-sentence ~15 tokens), Tier 1 (paragraph ~100 tokens), + Tier 2 (full CogDoc with frontmatter + sections + embeddings), + Tier 3 (raw passthrough, gated) +- `DecompositionRunner` engine using `AgentHarness.GenerateJSON()` with + JSON mode, per-tier schema validation, and one-retry error correction +- Interactive workbench TUI (`--workbench`): Bubbletea 2x2 viewport grid + with tier focus switching, re-run, and metrics bar +- Embedding co-generation via nomic-embed-text (128-dim + 768-dim Matryoshka) + for Tier 0, 1, and 2 output +- Content-addressed CogDoc storage at `.cog/mem/semantic/decompositions/` + with full YAML frontmatter, section index, and source refs +- Constellation indexing for vector + FTS5 retrieval of decomposition output +- Bus event lifecycle (`decompose.start/tier.start/tier.complete/complete/error`) + with file-based JSONL emission for standalone CLI runs +- Quality metrics: compression ratio, cross-tier embedding fidelity (cosine + similarity), schema conformance tracking +- Dashboard Decompose tab with recent decomposition history, per-tier + timing bars, and compression ratio color coding +- `GenerateJSON()` method on `AgentHarness` for general-purpose JSON-mode + LLM completions (reusable beyond decomposition) +- 52 tests (unit + integration) across 4 test files, including mock Ollama + server tests for prompt construction, retry logic, and event sequencing + +### Files +- `decompose.go` — Core engine, types, prompts, CLI, formatter (846 lines) +- `decompose_store.go` — Embedding generation, CogDoc storage (306 lines) +- `decompose_tui.go` — Bubbletea workbench TUI (351 lines) +- `decompose_test.go` — Unit tests (1,325 lines) +- `decompose_store_test.go` — Storage tests (238 lines) +- `decompose_tui_test.go` — TUI tests (97 lines) +- `decompose_integration_test.go` — E2E with live Ollama (310 lines) + ## [2.5.0] - 2026-04-14 — Gemma 4 default, dashboard model selector ### Changed diff --git a/README.md b/README.md index 76b3e26..84bc414 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ make build && ./cogos serve --workspace ~/my-project - **Anthropic Messages API proxy** -- Transparent proxy at `POST /v1/messages` that forwards to the real Anthropic API with streaming SSE passthrough. Enables `cog claude` to route Claude Code through the kernel via `ANTHROPIC_BASE_URL`. +- **Foveated decomposition pipeline** -- `cog decompose` processes any input through E4B into four tiers: Tier 0 (one-sentence, ~15 tokens), Tier 1 (paragraph, ~100 tokens), Tier 2 (full CogDoc with sections and embeddings), Tier 3 (raw, gated). Includes an interactive workbench TUI (`--workbench`), embedding co-generation via nomic-embed-text, content-addressed CogDoc storage, and bus event emission for observability. This is the DECOMPOSE stage of the CogOS Hypercycle. --- ## Architecture @@ -234,6 +235,9 @@ scripts/ Setup, CLI wrapper, e2e tests, experiment harnesses agent_harness.go Native agent loop (Ollama /api/chat) agent_tools.go Kernel-native tool implementations agent_serve.go Agent status HTTP endpoint +decompose.go Foveated decomposition engine (4-tier pipeline) +decompose_store.go Embedding generation and CogDoc storage +decompose_tui.go Interactive workbench TUI (Bubbletea) serve_messages.go Anthropic Messages API proxy serve_dashboard.go Embedded web dashboard mcp_http.go MCP Streamable HTTP transport @@ -255,7 +259,8 @@ mcp_mod3.go Mod3 voice tool bridge for MCP - MCP Streamable HTTP server (8 tools, JSON-RPC 2.0, sessions) - Anthropic Messages API proxy with streaming SSE - Native Go agent harness with adaptive interval and 6 kernel tools -- Embedded web dashboard with agent status and cycle history +- Embedded web dashboard with agent status, cycle history, and decomposition panel +- Foveated decomposition pipeline (`cog decompose`) with 4-tier output, workbench TUI, embeddings, and bus events - Library extraction: 7 packages in pkg/ (69 files, ~10.2K LOC, 190 tests) - Content-addressed blob store - Git-derived salience scoring diff --git a/agent_bus_inlet.go b/agent_bus_inlet.go new file mode 100644 index 0000000..0def58e --- /dev/null +++ b/agent_bus_inlet.go @@ -0,0 +1,319 @@ +// agent_bus_inlet.go — Dashboard chat inlet for the metabolic cycle. +// +// Wires the Mod³ dashboard to the cogos kernel's external-event channel: +// Mod³ → POST /v1/bus/send bus_id=bus_dashboard_chat +// → appendBusEvent dispatches to in-process handlers +// → this file's handler → engine.Process.SubmitExternal +// +// The inverse direction (kernel → Mod³) is handled by the `respond` tool +// (agent_tools_respond.go), which publishes to bus_dashboard_response; Mod³ +// subscribes via the existing /v1/events/stream SSE endpoint. +// +// Wiring: call InstallDashboardInlet(busMgr, process) once at daemon start +// alongside InstallTraceEmitter. The process argument may be nil (main daemon +// does not currently own a *engine.Process); in that case the handler still +// ensures the bus exists and logs incoming messages so the wiring is +// observable even before the v3 process is instantiated in this binary. +package main + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "sync" + "sync/atomic" + "time" + + "github.com/cogos-dev/cogos/internal/engine" +) + +// --- Pending user-message queue --- +// +// The primary delivery path for dashboard user messages is this package-global +// FIFO. The harness's runCycle drains it at the top of each iteration and +// enriches its observation, so the cycle observes the message even though the +// daemon does not currently own an *engine.Process. +// +// The queue is bounded (pendingMsgCap) and non-blocking: when full, the oldest +// entry is dropped with a warn log. The zero-value is usable; no init needed. + +// pendingUserMsg is one user message awaiting agent observation. +type pendingUserMsg struct { + Text string + SessionID string + Ts time.Time +} + +// pendingMsgCap caps the queue; messages beyond this drop the oldest entry. +// Sized generously for single-user bursts; keeps memory bounded if Mod³ ever +// runs away. +const pendingMsgCap = 100 + +var ( + pendingMu sync.Mutex + pendingMsgs []pendingUserMsg // FIFO (append tail, drain head) +) + +// enqueuePendingUserMessage appends m to the pending queue. If the queue is +// already at pendingMsgCap, the oldest entry is dropped and a warn is logged. +func enqueuePendingUserMessage(m pendingUserMsg) { + pendingMu.Lock() + defer pendingMu.Unlock() + if len(pendingMsgs) >= pendingMsgCap { + dropped := pendingMsgs[0] + pendingMsgs = pendingMsgs[1:] + log.Printf("[dashboard-inlet] warn: pending queue full (%d), dropping oldest msg (session=%s, age=%s)", + pendingMsgCap, dropped.SessionID, time.Since(dropped.Ts).Round(time.Second)) + } + pendingMsgs = append(pendingMsgs, m) +} + +// drainPendingUserMessages returns the current queue and clears it. Callers +// receive an independent slice — mutation does not affect the queue. +func drainPendingUserMessages() []pendingUserMsg { + pendingMu.Lock() + defer pendingMu.Unlock() + if len(pendingMsgs) == 0 { + return nil + } + out := make([]pendingUserMsg, len(pendingMsgs)) + copy(out, pendingMsgs) + pendingMsgs = pendingMsgs[:0] + return out +} + +// peekPendingUserMessages returns a copy of the queue without clearing it. +// Kept for future peek-and-ack semantics; currently unused by the harness. +func peekPendingUserMessages() []pendingUserMsg { + pendingMu.Lock() + defer pendingMu.Unlock() + if len(pendingMsgs) == 0 { + return nil + } + out := make([]pendingUserMsg, len(pendingMsgs)) + copy(out, pendingMsgs) + return out +} + +// dashboardChatBusID is the inbound bus (user → kernel). Mod³ produces here. +const dashboardChatBusID = "bus_dashboard_chat" + +// dashboardResponseBusID is the outbound bus (kernel → user). Mod³ subscribes. +const dashboardResponseBusID = "bus_dashboard_response" + +// dashboardInletProcess holds the engine.Process target for inbound messages. +// Allows late-binding: if the process is instantiated after the inlet is +// installed, RebindDashboardInlet can swap in the live instance without +// re-subscribing on the bus. Accessed via atomic Pointer to keep the hot path +// (handler) lock-free. +var dashboardInletProcess atomic.Pointer[engine.Process] + +// dashboardInletBusMgr retains the bus manager for the respond tool. +var dashboardInletBusMgr atomic.Pointer[busSessionManager] + +// InstallDashboardInlet wires bus_dashboard_chat → process.externalCh. +// +// mgr must be non-nil (no-op otherwise). process may be nil; it can be bound +// later via RebindDashboardInlet. The handler is registered on the bus +// manager and is not explicitly de-registered — lifetime tracks the daemon. +// +// Safe to call once at daemon start; subsequent calls overwrite state. +func InstallDashboardInlet(mgr *busSessionManager, process *engine.Process) { + if mgr == nil { + log.Printf("[dashboard-inlet] skip: nil bus manager") + return + } + // Publish manager: keep the FIRST install's manager as the canonical + // publisher for outbound agent responses. Subsequent installs (e.g. when + // registering the handler on additional workspace managers) must NOT + // overwrite this, otherwise responses end up written to a different + // workspace's on-disk bus directory than SSE consumers are reading from. + // The handler can still be added to every manager safely. + if dashboardInletBusMgr.Load() == nil { + dashboardInletBusMgr.Store(mgr) + } + if process != nil { + dashboardInletProcess.Store(process) + } + + ensureDashboardBuses(mgr) + + mgr.AddEventHandler("dashboard-inlet", handleDashboardChatEvent) + if process == nil { + log.Printf("[dashboard-inlet] installed (bus=%s, process=nil — submissions will be dropped until RebindDashboardInlet is called)", dashboardChatBusID) + } else { + log.Printf("[dashboard-inlet] installed (bus=%s)", dashboardChatBusID) + } +} + +// RebindDashboardInlet swaps in a live engine.Process after installation. +// Useful when the main package gains a process instance later in the startup +// sequence than InstallTraceEmitter runs. +func RebindDashboardInlet(process *engine.Process) { + if process == nil { + return + } + dashboardInletProcess.Store(process) + log.Printf("[dashboard-inlet] process bound") +} + +// ensureDashboardBuses creates the chat + response bus directories and +// registry entries if they don't already exist. Mirrors ensureTraceBus. +func ensureDashboardBuses(mgr *busSessionManager) { + for _, busID := range [...]string{dashboardChatBusID, dashboardResponseBusID} { + busDir := filepath.Join(mgr.busesDir(), busID) + if err := os.MkdirAll(busDir, 0o755); err != nil { + log.Printf("[dashboard-inlet] create bus dir %s: %v", busID, err) + continue + } + eventsFile := filepath.Join(busDir, "events.jsonl") + if _, err := os.Stat(eventsFile); os.IsNotExist(err) { + if f, err := os.Create(eventsFile); err == nil { + f.Close() + } + } + if err := mgr.registerBus(busID, "kernel:dashboard", "kernel:dashboard"); err != nil { + log.Printf("[dashboard-inlet] register bus %s: %v", busID, err) + } + } +} + +// handleDashboardChatEvent is the bus handler for dashboardChatBusID. +// It filters out non-target buses, extracts a user_message payload, builds a +// GateEvent, and submits to the engine process with non-blocking semantics. +// +// Expected Mod³ payload shape: +// +// {"type": "user_message", "text": "hello agent", "session_id": "...", "ts": "..."} +// +// Block.Type is set by Mod³ on the /v1/bus/send call; we accept either +// "user_message" or the generic "message" type and use the payload's "text" +// or "content" field. Everything else is dropped. +func handleDashboardChatEvent(busID string, block *CogBlock) { + if busID != dashboardChatBusID || block == nil { + return + } + + // Skip anything that originated from the kernel itself — otherwise a + // future self-echo could create a feedback loop. + if block.From == "kernel:cogos" || block.From == "kernel:dashboard" { + return + } + + text := extractDashboardText(block) + if text == "" { + log.Printf("[dashboard-inlet] drop: no text in block seq=%d from=%s type=%s", block.Seq, block.From, block.Type) + return + } + + // Parse ts / session_id once — both the pending-queue path and the + // (future) engine.Process path use them. + ts := time.Now().UTC() + if block.Ts != "" { + if parsed, err := time.Parse(time.RFC3339Nano, block.Ts); err == nil { + ts = parsed + } + } + sessionID := "" + if v, ok := block.Payload["session_id"].(string); ok { + sessionID = v + } + + // Primary delivery: enqueue onto the pending queue the harness's + // runCycle drains. This is what actually reaches the running loop. + enqueuePendingUserMessage(pendingUserMsg{ + Text: text, + SessionID: sessionID, + Ts: ts, + }) + log.Printf("[dashboard-inlet] queued user message (text_len=%d session=%s from=%s)", len(text), sessionID, block.From) + + // Secondary delivery: if an engine.Process has been bound via + // RebindDashboardInlet, also forward the message as a GateEvent. + // Forward-compatible — currently no-op in the daemon, which does not + // own a *engine.Process. + proc := dashboardInletProcess.Load() + if proc == nil { + return + } + + evt := &engine.GateEvent{ + Type: "user.message", + Content: text, + Timestamp: ts, + SessionID: sessionID, + Data: map[string]interface{}{ + "source": "dashboard", + "bus_id": busID, + "block_seq": block.Seq, + "block_hash": block.Hash, + "from": block.From, + "mod3_type": block.Type, + }, + } + + if !proc.SubmitExternal(evt) { + log.Printf("[dashboard-inlet] warn: externalCh full, dropping dashboard message (seq=%d from=%s)", block.Seq, block.From) + } +} + +// extractDashboardText pulls the message body out of a dashboard chat block. +// Accepts either "text" or "content" on the payload. +func extractDashboardText(block *CogBlock) string { + if block == nil || block.Payload == nil { + return "" + } + if v, ok := block.Payload["text"].(string); ok && v != "" { + return v + } + if v, ok := block.Payload["content"].(string); ok && v != "" { + return v + } + return "" +} + +// publishDashboardResponse is the inverse of the inlet: it publishes a +// structured agent_response onto bus_dashboard_response for Mod³ to consume. +// Exposed to the respond tool in agent_tools_respond.go; kept in this file so +// the two directions of the chat channel sit next to each other. +// +// sessionID, when non-empty, is included in the payload so subscribers can +// filter on it — without it Mod³ broadcasts replies to every connected +// client and multi-client setups see cross-talk. Pair with the dashboard +// inlet's per-turn session_id capture (handleDashboardChatEvent populates +// pendingUserMsg.SessionID; ServeAgent.runCycle threads it through ctx via +// WithSessionID, the respond tool reads it back here). +func publishDashboardResponse(text, reasoning, sessionID string) (int, error) { + mgr := dashboardInletBusMgr.Load() + if mgr == nil { + return 0, errDashboardNotInstalled + } + + // Defensive: make sure the response bus exists even if Install was called + // with only a chat subscriber (e.g. a misordered startup). + ensureDashboardBuses(mgr) + + payload := map[string]interface{}{ + "type": "agent_response", + "text": text, + "ts": time.Now().UTC().Format(time.RFC3339Nano), + } + if reasoning != "" { + payload["reasoning"] = reasoning + } + if sessionID != "" { + payload["session_id"] = sessionID + } + + raw, err := json.Marshal(payload) + if err != nil { + return 0, err + } + n := len(raw) + + if _, err := mgr.appendBusEvent(dashboardResponseBusID, "agent_response", "kernel:cogos", payload); err != nil { + return 0, err + } + return n, nil +} diff --git a/agent_decompose.go b/agent_decompose.go new file mode 100644 index 0000000..a30e663 --- /dev/null +++ b/agent_decompose.go @@ -0,0 +1,298 @@ +// agent_decompose.go — Wire decomposition into the agent loop. +// +// After each cycle, the assessment is decomposed into Tier 0 (one sentence). +// The last N Tier 0 sentences are injected into the next observation as +// rolling compressed memory. This is the first self-feeding wire in the +// CogOS Hypercycle: the agent observes its own compressed history. +// +// The decomposition uses the same AgentHarness that runs the assessment, +// calling GenerateJSON with Tier 0 prompts. If decomposition fails (Ollama +// busy, timeout, etc.), the cycle continues without it — best effort. + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + // maxRollingMemory is how many Tier 0 sentences to keep in the rolling buffer. + maxRollingMemory = 10 + + // decompTimeoutSec is the maximum time to spend on per-cycle decomposition. + // Must be short enough not to delay the next cycle. + decompTimeoutSec = 60 +) + +// cycleMemoryEntry is one cycle's compressed memory. +type cycleMemoryEntry struct { + Cycle int64 `json:"cycle"` + Action string `json:"action"` + Urgency float64 `json:"urgency"` + Sentence string `json:"sentence"` // Tier 0 + Timestamp time.Time `json:"timestamp"` + Quality string `json:"quality"` // "decomposed" | "fallback" +} + +// agentCycleMemory maintains a rolling buffer of decomposed cycle summaries. +type agentCycleMemory struct { + mu sync.RWMutex + entries []cycleMemoryEntry + maxSize int +} + +func newAgentCycleMemory(maxSize int) *agentCycleMemory { + return &agentCycleMemory{ + entries: make([]cycleMemoryEntry, 0, maxSize), + maxSize: maxSize, + } +} + +// append adds a new entry, evicting the oldest if at capacity. +func (m *agentCycleMemory) append(entry cycleMemoryEntry) { + m.mu.Lock() + defer m.mu.Unlock() + if len(m.entries) >= m.maxSize { + m.entries = m.entries[1:] + } + m.entries = append(m.entries, entry) +} + +// recent returns the last n entries (or fewer if not enough). +func (m *agentCycleMemory) recent(n int) []cycleMemoryEntry { + m.mu.RLock() + defer m.mu.RUnlock() + if n > len(m.entries) { + n = len(m.entries) + } + result := make([]cycleMemoryEntry, n) + copy(result, m.entries[len(m.entries)-n:]) + return result +} + +// len returns the current buffer size. +func (m *agentCycleMemory) len() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.entries) +} + +// formatForObservation renders the rolling memory as a compact string +// suitable for injection into gatherObservation(). +func (m *agentCycleMemory) formatForObservation() string { + entries := m.recent(maxRollingMemory) + if len(entries) == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString("\n=== Recent Cycle Memory (compressed) ===\n") + for _, e := range entries { + ago := time.Since(e.Timestamp).Round(time.Minute) + qualityTag := "" + if e.Quality == "fallback" { + qualityTag = " [fallback]" + } + sb.WriteString(fmt.Sprintf("[%s ago] %s (u=%.1f)%s: %s\n", + formatAgo(ago), e.Action, e.Urgency, qualityTag, e.Sentence)) + } + return sb.String() +} + +// formatAgo produces a human-readable duration like "5m" or "2h". +func formatAgo(d time.Duration) string { + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) +} + +// decomposeAndStore runs Tier 0 decomposition on a cycle's assessment +// and stores the result in the rolling memory buffer. +// Called asynchronously after each cycle completes — best effort. +func (sa *ServeAgent) decomposeAndStore(ctx context.Context, cycle int64, assessment *Assessment) { + // Build input text from the assessment + input := fmt.Sprintf("Agent cycle %d assessment:\nAction: %s\nUrgency: %.1f\nReason: %s\nTarget: %s", + cycle, assessment.Action, assessment.Urgency, assessment.Reason, assessment.Target) + + // Add workspace context for richer decomposition + if obs := sa.lastObservation; obs != "" { + // Truncate observation to avoid overwhelming Tier 0 + if len(obs) > 500 { + obs = obs[:500] + "..." + } + input += "\n\nWorkspace state at time of assessment:\n" + obs + } + + // Run Tier 0 decomposition with a tight timeout + decompCtx, cancel := context.WithTimeout(ctx, decompTimeoutSec*time.Second) + defer cancel() + + // First attempt + content, err := sa.harness.GenerateJSON(decompCtx, tier0SystemPrompt(), tierUserPrompt(input)) + if err != nil { + // Retry once after a brief pause (GPU might be momentarily busy) + time.Sleep(2 * time.Second) + retryCtx, retryCancel := context.WithTimeout(ctx, decompTimeoutSec*time.Second) + content, err = sa.harness.GenerateJSON(retryCtx, tier0SystemPrompt(), tierUserPrompt(input)) + retryCancel() + } + if err != nil { + log.Printf("[agent] cycle %d: decompose failed after retry: %v", cycle, err) + // Fall back to a mechanical summary — still useful as memory + sa.cycleMemory.append(cycleMemoryEntry{ + Cycle: cycle, + Action: assessment.Action, + Urgency: assessment.Urgency, + Sentence: fmt.Sprintf("Cycle %d: %s (urgency %.1f) — %s", cycle, assessment.Action, assessment.Urgency, assessment.Reason), + Timestamp: time.Now(), + Quality: "fallback", + }) + return + } + + var tier0 Tier0Result + if err := json.Unmarshal([]byte(content), &tier0); err != nil { + log.Printf("[agent] cycle %d: decompose parse failed: %v", cycle, err) + // Fall back to reason string + sa.cycleMemory.append(cycleMemoryEntry{ + Cycle: cycle, + Action: assessment.Action, + Urgency: assessment.Urgency, + Sentence: assessment.Reason, + Timestamp: time.Now(), + Quality: "fallback", + }) + return + } + + sa.cycleMemory.append(cycleMemoryEntry{ + Cycle: cycle, + Action: assessment.Action, + Urgency: assessment.Urgency, + Sentence: tier0.Summary, + Timestamp: time.Now(), + Quality: "decomposed", + }) + + log.Printf("[agent] cycle %d: decomposed → %q", cycle, tier0.Summary) + + // Emit decomposition event to the bus + sa.emitEvent("agent.decompose", map[string]interface{}{ + "cycle": cycle, + "action": assessment.Action, + "sentence": tier0.Summary, + }) + + // Persist to disk so memory survives kernel restarts + sa.persistCycleMemory() +} + +// persistCycleMemory writes the rolling buffer to disk as JSON. +func (sa *ServeAgent) persistCycleMemory() { + memDir := filepath.Join(sa.root, ".cog", ".state", "agent") + if err := os.MkdirAll(memDir, 0o755); err != nil { + return + } + memFile := filepath.Join(memDir, "cycle-memory.json") + + entries := sa.cycleMemory.recent(maxRollingMemory) + data, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return + } + _ = os.WriteFile(memFile, data, 0o644) +} + +// loadCycleMemory restores the rolling buffer from disk on startup. +func (sa *ServeAgent) loadCycleMemory() { + memFile := filepath.Join(sa.root, ".cog", ".state", "agent", "cycle-memory.json") + data, err := os.ReadFile(memFile) + if err != nil { + return // No prior memory — fresh start + } + + var entries []cycleMemoryEntry + if err := json.Unmarshal(data, &entries); err != nil { + log.Printf("[agent] failed to load cycle memory: %v", err) + return + } + + for _, e := range entries { + sa.cycleMemory.append(e) + } + log.Printf("[agent] loaded %d cycle memory entries from disk", len(entries)) +} + +// --- Cycle Trace Storage --- + +// cycleTrace is the full record of one agent cycle, written to disk. +type cycleTrace struct { + Cycle int64 `json:"cycle"` + Timestamp time.Time `json:"timestamp"` + DurationMs int64 `json:"duration_ms"` + Action string `json:"action"` + Urgency float64 `json:"urgency"` + Reason string `json:"reason"` + Target string `json:"target"` + Observation string `json:"observation"` + Result string `json:"result,omitempty"` +} + +const maxStoredTraces = 20 + +// storeCycleTrace writes the full cycle record to disk. +// Keeps the last N traces in a JSON array file. +func (sa *ServeAgent) storeCycleTrace(cycle int64, assessment *Assessment, observation, result string, duration time.Duration) { + traceDir := filepath.Join(sa.root, ".cog", ".state", "agent") + if err := os.MkdirAll(traceDir, 0o755); err != nil { + return + } + traceFile := filepath.Join(traceDir, "cycle-traces.json") + + // Load existing traces + var traces []cycleTrace + if data, err := os.ReadFile(traceFile); err == nil { + json.Unmarshal(data, &traces) + } + + // Append new trace + traces = append(traces, cycleTrace{ + Cycle: cycle, + Timestamp: time.Now(), + DurationMs: duration.Milliseconds(), + Action: assessment.Action, + Urgency: assessment.Urgency, + Reason: assessment.Reason, + Target: assessment.Target, + Observation: observation, + Result: result, + }) + + // Keep only last N + if len(traces) > maxStoredTraces { + traces = traces[len(traces)-maxStoredTraces:] + } + + data, err := json.MarshalIndent(traces, "", " ") + if err != nil { + return + } + _ = os.WriteFile(traceFile, data, 0o644) +} + +// truncate returns at most n characters of s, appending "..." if truncated. +func agentTruncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/agent_harness.go b/agent_harness.go index 6757731..9851f20 100644 --- a/agent_harness.go +++ b/agent_harness.go @@ -15,10 +15,102 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "time" + + "github.com/cogos-dev/cogos/internal/engine" + "github.com/cogos-dev/cogos/trace" ) +// cycleIDFromContext extracts a cycle-trace correlation ID from ctx, or +// returns "" if none. See trace_emit.go for the key type. +func cycleIDFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + v, _ := ctx.Value(cycleIDKey{}).(string) + return v +} + +// WithCycleID returns ctx carrying the given cycle-trace ID. Callers (e.g. +// ServeAgent.runCycle) should wrap ctx with this before invoking Assess / +// Execute so tool-dispatch emission can correlate events across the +// iteration. +func WithCycleID(ctx context.Context, id string) context.Context { + if id == "" { + return ctx + } + return context.WithValue(ctx, cycleIDKey{}, id) +} + +// sessionIDKey is the context key that carries the dashboard session_id of +// the user turn currently being processed. The respond tool reads this so +// its reply can be tagged with the originating session, letting Mod³ filter +// broadcasts and avoid cross-talk between simultaneously-connected clients. +type sessionIDKey struct{} + +// sessionIDFromContext extracts the dashboard session_id from ctx, or "" if +// none. Empty string is the explicit "no session" signal — publishers treat +// it as a broadcast to anyone listening (the legacy behavior). +func sessionIDFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + v, _ := ctx.Value(sessionIDKey{}).(string) + return v +} + +// WithSessionID returns ctx carrying the given dashboard session_id. Set by +// ServeAgent.runCycle when a pending user message is being observed so the +// downstream respond tool can stamp its reply with the correct session. +func WithSessionID(ctx context.Context, id string) context.Context { + if id == "" { + return ctx + } + return context.WithValue(ctx, sessionIDKey{}, id) +} + +// sessionIDsKey carries the de-duplicated list of dashboard session_ids for a +// cycle that drained multiple user messages. The respond tool fans out its +// reply across this list so every originating session/tab sees the response — +// without it, a cycle consuming messages from N clients would only reply on +// whichever session_id happened to be first in the pending queue. +// +// When absent (or empty), publishers fall back to sessionIDFromContext — the +// single-session path preserved for the common case of one message per cycle. +type sessionIDsKey struct{} + +// sessionIDsFromContext returns the fan-out list of session_ids for the +// current cycle's reply, or nil if none was set. An empty slice is treated as +// no-list (callers should use the single-id path). +func sessionIDsFromContext(ctx context.Context) []string { + if ctx == nil { + return nil + } + v, _ := ctx.Value(sessionIDsKey{}).([]string) + if len(v) == 0 { + return nil + } + return v +} + +// WithSessionIDs returns ctx carrying the given fan-out list of session_ids. +// Set by ServeAgent.runCycle after draining the pending queue so downstream +// publishers (respond tool, auto-fallback) can emit one reply per unique +// session. Nil/empty ids are ignored — callers keep WithSessionID for the +// single-session case. +func WithSessionIDs(ctx context.Context, ids []string) context.Context { + if len(ids) == 0 { + return ctx + } + // Copy so later mutations on the caller's slice can't alter the stored + // list — ctx values are treated as immutable by convention. + cp := make([]string, len(ids)) + copy(cp, ids) + return context.WithValue(ctx, sessionIDsKey{}, cp) +} + // --- Wire protocol types (Ollama native /api/chat) --- // // Uses Ollama's native API instead of the OpenAI-compatible shim because: @@ -35,6 +127,7 @@ type agentChatRequest struct { Stream bool `json:"stream"` Think bool `json:"think"` // explicit thinking control Format string `json:"format,omitempty"` // "json" for structured output + Options map[string]interface{} `json:"options,omitempty"` // Ollama model options (num_ctx, temperature, etc.) } // agentChatMessage is a single message in the conversation. @@ -63,8 +156,16 @@ type agentChatResponse struct { Content string `json:"content"` ToolCalls []agentToolCall `json:"tool_calls,omitempty"` } `json:"message"` - Done bool `json:"done"` + Done bool `json:"done"` DoneReason string `json:"done_reason,omitempty"` + + // Ollama performance metrics (nanoseconds) + TotalDuration int64 `json:"total_duration,omitempty"` + LoadDuration int64 `json:"load_duration,omitempty"` + PromptEvalCount int `json:"prompt_eval_count,omitempty"` + PromptEvalDuration int64 `json:"prompt_eval_duration,omitempty"` + EvalCount int `json:"eval_count,omitempty"` + EvalDuration int64 `json:"eval_duration,omitempty"` } // --- Tool definition types --- @@ -127,7 +228,7 @@ func NewAgentHarness(cfg AgentHarnessConfig) *AgentHarness { tools: nil, toolFuncs: make(map[string]ToolFunc), httpClient: &http.Client{ - Timeout: 120 * time.Second, + Timeout: 180 * time.Second, }, maxTurns: maxTurns, } @@ -153,6 +254,7 @@ func (h *AgentHarness) Assess(ctx context.Context, systemPrompt, observation str Stream: false, Think: false, // disable thinking — we want clean JSON output Format: "json", + Options: map[string]interface{}{"num_ctx": 8192}, // 8K context is plenty for assessment } resp, err := h.chatCompletion(ctx, req) @@ -160,6 +262,15 @@ func (h *AgentHarness) Assess(ctx context.Context, systemPrompt, observation str return nil, fmt.Errorf("assess: %w", err) } + // Log Ollama performance metrics + if resp.PromptEvalCount > 0 { + promptTokS := float64(resp.PromptEvalCount) / (float64(resp.PromptEvalDuration) / 1e9) + evalTokS := float64(resp.EvalCount) / (float64(resp.EvalDuration) / 1e9) + totalS := float64(resp.TotalDuration) / 1e9 + log.Printf("[agent] assess metrics: %d prompt tok (%.0f tok/s) + %d eval tok (%.0f tok/s) = %.1fs total", + resp.PromptEvalCount, promptTokS, resp.EvalCount, evalTokS, totalS) + } + content := resp.Message.Content var assessment Assessment @@ -186,6 +297,7 @@ func (h *AgentHarness) Execute(ctx context.Context, systemPrompt, task string) ( Tools: h.tools, Stream: false, Think: false, // disable thinking for tool loop + Options: map[string]interface{}{"num_ctx": 8192}, // 8K context for tool loop } resp, err := h.chatCompletion(ctx, req) @@ -193,6 +305,13 @@ func (h *AgentHarness) Execute(ctx context.Context, systemPrompt, task string) ( return "", fmt.Errorf("execute turn %d: %w", turn, err) } + // Log per-turn metrics + if resp.PromptEvalCount > 0 { + totalS := float64(resp.TotalDuration) / 1e9 + log.Printf("[agent] execute turn %d: %d prompt tok + %d eval tok = %.1fs", + turn, resp.PromptEvalCount, resp.EvalCount, totalS) + } + msg := resp.Message // No tool calls — model is done. Return the content. @@ -206,19 +325,51 @@ func (h *AgentHarness) Execute(ctx context.Context, systemPrompt, task string) ( ToolCalls: msg.ToolCalls, }) - // Dispatch each tool call and collect results. + // Dispatch each tool call and collect results. Track whether the + // sanctioned `wait` tool was invoked — if so, we terminate the loop + // after this turn's dispatch rather than asking the model for another + // round. This gives the model a clean "nothing to do" exit. + var waitInvoked bool + var waitReason string for _, tc := range msg.ToolCalls { + // Dispatch with timing so we can emit a cycle.tool_dispatch trace + // event. Emission is best-effort and never blocks the tool loop. + toolStart := time.Now() result, err := h.dispatchTool(ctx, tc) + toolDuration := time.Since(toolStart) + if cycleID := cycleIDFromContext(ctx); cycleID != "" { + ev, bErr := trace.NewToolDispatch( + engine.TraceIdentity(), + cycleID, + tc.Function.Name, + tc.Function.Arguments, + toolDuration, + err, + ) + if bErr == nil { + emitCycleEvent(ev) + } + } if err != nil { // Tool errors go back to the model as content, not Go errors. result = []byte(fmt.Sprintf(`{"error": %q}`, err.Error())) } + if tc.Function.Name == waitToolName { + waitInvoked = true + if r := extractWaitReason(result); r != "" { + waitReason = r + } + } messages = append(messages, agentChatMessage{ Role: "tool", ToolCallID: tc.ID, Content: string(result), }) } + + if waitInvoked { + return fmt.Sprintf("waited: %s", waitReason), nil + } } return "", fmt.Errorf("execute: hit max turns (%d) without completion", h.maxTurns) diff --git a/agent_harness_test.go b/agent_harness_test.go index 6e31145..657c9c7 100644 --- a/agent_harness_test.go +++ b/agent_harness_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" ) @@ -376,6 +377,47 @@ func TestAgentHarness_DefaultMaxTurns(t *testing.T) { } } +// --- Test: wait tool terminates the Execute loop after a single round-trip --- +// +// This is the structural fix for the justification-loop pathology: when the +// model invokes `wait`, Execute returns cleanly without asking for another +// turn. See cog://mem/semantic/research/cogos-vs-agent-corpus-comparison-2026-04-16 +// (Gap 1) for context. + +func TestAgentHarness_Execute_WaitTerminates(t *testing.T) { + var callCount atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount.Add(1) + w.Header().Set("Content-Type", "application/json") + // Every call returns a wait tool call — if the loop doesn't terminate, + // this server gets hit more than once and the assertion below catches it. + resp := makeToolCallResponse("call_wait", "wait", `{"reason":"observation handled"}`) + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + h := NewAgentHarness(AgentHarnessConfig{ + OllamaURL: server.URL, + Model: "test", + }) + RegisterWaitTool(h, "/tmp/unused-workspace-root") + + result, err := h.Execute(context.Background(), "system", "observe quietly") + if err != nil { + t.Fatalf("execute: %v", err) + } + if got := callCount.Load(); got != 1 { + t.Errorf("expected server to be called exactly once (loop should terminate on wait), got %d calls", got) + } + if !strings.Contains(result, "waited") { + t.Errorf("expected result to contain 'waited', got %q", result) + } + if !strings.Contains(result, "observation handled") { + t.Errorf("expected result to include the wait reason, got %q", result) + } +} + // --- Helpers --- // newCannedServer returns an httptest.Server that always responds with the given content. diff --git a/agent_observation.go b/agent_observation.go new file mode 100644 index 0000000..f2e11aa --- /dev/null +++ b/agent_observation.go @@ -0,0 +1,342 @@ +// agent_observation.go — Typed observation records for the metabolic cycle. +// +// This file replaces the ad-hoc string-concat observation shape with a +// schema-bound record stream. The runCycle drains pending user messages and +// fetches the foveated-context manifest, converts both into ObservationRecord +// values, and serializes the set into a JSON array that the Assess phase sees. +// +// Why: the old shape used a hardcoded `=== PRIORITY` prose-prepend to force +// Gemma E4B to prioritize user messages. That was a prose-patch on a +// structural problem — the observation should already carry salience as data +// so the model doesn't need to be browbeaten into reading it. Typed records +// with user_message salience=1.0 put the priority signal where it belongs: +// in the substrate, not in English. +// +// Salience contract: +// - user_message: 1.0 — always highest. +// - knowledge_block: inherits max source.Salience from the foveated manifest. +// - bus_event / git_state / coherence_state: fixed bands (0.3..0.5). + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// ObservationRecord is one unit of observable state fed to the Assess phase. +// +// Kinds: +// - user_message — pending dashboard chat input (always highest salience) +// - knowledge_block — a foveated-manifest block (tier 0..3) +// - bus_event — a recent non-system bus event worth observing +// - git_state — summary of working-tree state +// - coherence_state — drift/OK summary +type ObservationRecord struct { + Kind string `json:"kind"` + Source string `json:"source"` + Digest string `json:"digest,omitempty"` + Salience float64 `json:"salience"` + Tier string `json:"tier,omitempty"` + Tokens int `json:"tokens,omitempty"` + Preview string `json:"preview,omitempty"` + Content string `json:"content,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// foveatedManifestRequest mirrors the JSON body handleFoveatedContext decodes. +// Kept as a local type so this file does not import the engine package (the +// main package compiles against engine's public surface only). +type foveatedManifestRequest struct { + Prompt string `json:"prompt"` + Query string `json:"query,omitempty"` + Iris foveatedManifestIris `json:"iris"` + Profile string `json:"profile,omitempty"` + SessionID string `json:"session_id,omitempty"` +} + +type foveatedManifestIris struct { + Size int `json:"size"` + Used int `json:"used"` +} + +// foveatedManifestResponse mirrors engine.foveatedResponse for decode. +type foveatedManifestResponse struct { + Context string `json:"context"` + Tokens int `json:"tokens"` + Anchor string `json:"anchor"` + Goal string `json:"goal"` + IrisPressure float64 `json:"iris_pressure"` + CoherenceScore float64 `json:"coherence_score"` + TierBreakdown map[string]int `json:"tier_breakdown"` + EffectiveBudget int `json:"effective_budget"` + Blocks []foveatedManifestBlock `json:"blocks"` +} + +type foveatedManifestBlock struct { + Tier string `json:"tier"` + Name string `json:"name"` + Hash string `json:"hash"` + Tokens int `json:"tokens"` + Stability int `json:"stability"` + Preview string `json:"preview,omitempty"` + Sources []foveatedManifestBlockSource `json:"sources,omitempty"` +} + +type foveatedManifestBlockSource struct { + URI string `json:"uri"` + Title string `json:"title,omitempty"` + Path string `json:"path,omitempty"` + Salience float64 `json:"salience,omitempty"` + Reason string `json:"reason,omitempty"` + Summary string `json:"summary,omitempty"` +} + +// kernelLoopbackURL returns the base URL used for in-process HTTP calls to +// the kernel's own /v1 surface. We're running inside the kernel daemon, so +// localhost with the configured port is always correct. +func kernelLoopbackURL() string { + if v := os.Getenv("COG_KERNEL_PORT"); v != "" { + return "http://localhost:" + v + } + return fmt.Sprintf("http://localhost:%d", defaultServePort) +} + +// fetchFoveatedManifest calls the kernel's own /v1/context/foveated endpoint +// via HTTP loopback. This is in-process (same daemon, same listener), so the +// hop is cheap and avoids re-plumbing an engine.Process into the agent. +// +// prompt may be empty; when empty, the manifest falls back to salience-only +// scoring for knowledge selection. +// +// Returns (nil, err) on network / decode / non-2xx. Callers should treat the +// manifest as best-effort — a failure here does not invalidate the cycle. +func fetchFoveatedManifest(ctx context.Context, prompt string) (*foveatedManifestResponse, error) { + // Conservative iris signal — the agent cycle runs at 8K ctx (see harness + // Options), with generous headroom for the assessment turn itself. The + // manifest endpoint clamps its budget from this, so pressure≈used/size. + req := foveatedManifestRequest{ + Prompt: prompt, + Iris: foveatedManifestIris{Size: 8192, Used: 2048}, + Profile: "agent_harness", + SessionID: "agent_harness", + } + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal foveated request: %w", err) + } + + // Short timeout — the manifest is an enrichment, not a critical path. + callCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + httpReq, err := http.NewRequestWithContext(callCtx, http.MethodPost, kernelLoopbackURL()+"/v1/context/foveated", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("foveated loopback: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return nil, fmt.Errorf("foveated loopback: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) + } + + var out foveatedManifestResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("decode foveated response: %w", err) + } + return &out, nil +} + +// buildObservationRecords converts the three inputs (pending user messages, +// foveated manifest blocks, base prose observation) into a single typed record +// stream, ordered by salience descending. +// +// The caller owns deciding when each input is available; the function treats +// nil/empty inputs as "skip that input cleanly" and does no I/O itself. +func buildObservationRecords( + pending []pendingUserMsg, + manifest *foveatedManifestResponse, + gitSummary string, + coherenceSummary string, + busSummary string, +) []ObservationRecord { + now := time.Now().UTC() + var records []ObservationRecord + + // 1. User messages — salience 1.0 (always top). + for _, m := range pending { + ts := m.Ts + if ts.IsZero() { + ts = now + } + records = append(records, ObservationRecord{ + Kind: "user_message", + Source: "mod3_dashboard", + Salience: 1.0, + Content: m.Text, + Timestamp: ts, + }) + } + + // 2. Knowledge blocks from the foveated manifest. Each block inherits the + // max source.Salience — this is the substrate signal we want surfaced. + if manifest != nil { + for _, blk := range manifest.Blocks { + salience := 0.0 + for _, src := range blk.Sources { + if src.Salience > salience { + salience = src.Salience + } + } + // Block without sources (e.g. node health, events) — give it a + // middling default so it still sorts below user messages but + // above the raw prose state. + if len(blk.Sources) == 0 { + salience = 0.4 + } + records = append(records, ObservationRecord{ + Kind: "knowledge_block", + Source: "foveated_manifest", + Digest: blk.Hash, + Salience: salience, + Tier: blk.Tier, + Tokens: blk.Tokens, + Preview: blk.Preview, + Timestamp: now, + }) + } + } + + // 3. Baseline prose state — git / coherence / bus activity. These are + // single-record summaries rather than per-file expansion; the point + // is that the model sees them at a known low salience and stops + // mistaking them for the primary signal. + if s := strings.TrimSpace(gitSummary); s != "" { + records = append(records, ObservationRecord{ + Kind: "git_state", + Source: "workspace_state", + Salience: 0.3, + Preview: s, + Timestamp: now, + }) + } + if s := strings.TrimSpace(coherenceSummary); s != "" { + records = append(records, ObservationRecord{ + Kind: "coherence_state", + Source: "workspace_state", + Salience: 0.35, + Preview: s, + Timestamp: now, + }) + } + if s := strings.TrimSpace(busSummary); s != "" { + records = append(records, ObservationRecord{ + Kind: "bus_event", + Source: "bus_registry", + Salience: 0.5, + Preview: s, + Timestamp: now, + }) + } + + return records +} + +// renderObservationRecords serializes a record stream to the JSON array shape +// the Assess phase consumes. The format is: +// +// {"records": [...], "record_count": N, "generated_at": "..."} +// +// Wrapping in an object (rather than a bare array) leaves room for future +// metadata — anchor, goal, iris pressure — without breaking the model's +// expectations. The envelope is stable; internal fields can grow. +// +// If marshaling fails (shouldn't happen with the fixed struct shape), returns +// a best-effort error string so the cycle continues rather than aborting. +func renderObservationRecords(records []ObservationRecord, anchor, goal string) string { + env := struct { + Records []ObservationRecord `json:"records"` + RecordCount int `json:"record_count"` + GeneratedAt time.Time `json:"generated_at"` + Anchor string `json:"anchor,omitempty"` + Goal string `json:"goal,omitempty"` + }{ + Records: records, + RecordCount: len(records), + GeneratedAt: time.Now().UTC(), + Anchor: anchor, + Goal: goal, + } + buf, err := json.MarshalIndent(env, "", " ") + if err != nil { + return fmt.Sprintf(`{"records": [], "error": %q}`, err.Error()) + } + return string(buf) +} + +// firstUserMessageText returns the first pending user message's text (for +// anchor-query extraction), or an empty string if none are pending. Multiple +// pending messages keep using the first as the anchor on the assumption that +// back-to-back turns are topically related; the rest still appear as records. +func firstUserMessageText(pending []pendingUserMsg) string { + for _, m := range pending { + if s := strings.TrimSpace(m.Text); s != "" { + return s + } + } + return "" +} + +// firstUserMessageSessionID returns the first pending user message's +// session_id, or "" if none. Used to tag the cycle's reply so Mod³ can +// route it to the originating client instead of broadcasting. +func firstUserMessageSessionID(pending []pendingUserMsg) string { + for _, m := range pending { + if m.SessionID != "" { + return m.SessionID + } + } + return "" +} + +// uniqueUserMessageSessionIDs returns the distinct session_ids observed across +// the pending queue, in first-seen order. Messages with an empty session_id +// collapse into a single "" entry that publishers interpret as a broadcast. +// +// Used by the cycle's reply paths (respond tool + auto-fallback) to fan out a +// single agent response to every originating session when drainPendingUserMessages +// returned messages from multiple tabs/clients. Without this fan-out, a cycle +// that consumed N messages from N different sessions would only reply on one +// session (whichever was first), leaving the others silently waiting. +// +// Returns nil when pending is empty so callers can distinguish "no reply owed" +// from "broadcast reply owed". +func uniqueUserMessageSessionIDs(pending []pendingUserMsg) []string { + if len(pending) == 0 { + return nil + } + seen := make(map[string]bool, len(pending)) + out := make([]string, 0, len(pending)) + for _, m := range pending { + if seen[m.SessionID] { + continue + } + seen[m.SessionID] = true + out = append(out, m.SessionID) + } + return out +} diff --git a/agent_serve.go b/agent_serve.go index 0a3cd49..ca0fd09 100644 --- a/agent_serve.go +++ b/agent_serve.go @@ -18,22 +18,82 @@ package main import ( "context" + "encoding/json" "fmt" "log" + "net/http" "os" "os/exec" + "path/filepath" "strings" "sync" "sync/atomic" "time" + + "github.com/cogos-dev/cogos/internal/engine" + "github.com/cogos-dev/cogos/internal/linkfeed" + "github.com/cogos-dev/cogos/trace" + "github.com/fsnotify/fsnotify" + "github.com/google/uuid" ) const ( - agentIntervalMin = 5 * time.Minute // start fast + agentIntervalMin = 3 * time.Minute // fast cycles now that num_ctx=8192 makes assess ~7s agentIntervalMax = 30 * time.Minute // relax to this after consecutive sleeps agentBusID = "bus_agent_harness" ) +// AgentStatusResponse is the JSON payload for GET /v1/agent/status. +type AgentStatusResponse struct { + Alive bool `json:"alive"` + Running bool `json:"running"` + Uptime string `json:"uptime"` + UptimeSec int64 `json:"uptime_sec"` + CycleCount int64 `json:"cycle_count"` + LastCycle string `json:"last_cycle,omitempty"` // RFC3339 + LastAction string `json:"last_action,omitempty"` + LastUrgency float64 `json:"last_urgency"` + LastReason string `json:"last_reason,omitempty"` + LastDurMs int64 `json:"last_duration_ms"` + Interval string `json:"interval"` + Model string `json:"model"` + + // Activity awareness (enriched) + Activity *AgentActivitySummary `json:"activity,omitempty"` + Memory []AgentMemoryEntry `json:"memory,omitempty"` + Proposals []AgentProposalEntry `json:"proposals,omitempty"` + Inbox *linkfeed.AgentInboxSummary `json:"inbox,omitempty"` +} + +// AgentActivitySummary is the system activity snapshot from the bus registry. +type AgentActivitySummary struct { + UserPresence string `json:"user_presence"` // "active", "recent", "idle", "unknown" + UserLastEventAgo string `json:"user_last_event_ago"` // "3m", "2h" + ClaudeCodeActive int `json:"claude_code_active"` + ClaudeCodeEvents int64 `json:"claude_code_events"` + TotalEventDelta int64 `json:"total_event_delta"` + HottestBus string `json:"hottest_bus,omitempty"` + HottestDelta int64 `json:"hottest_delta"` +} + +// AgentMemoryEntry is a rolling memory item for the API. +type AgentMemoryEntry struct { + Cycle int64 `json:"cycle"` + Action string `json:"action"` + Urgency float64 `json:"urgency"` + Sentence string `json:"sentence"` + Ago string `json:"ago"` // "5m", "2h" +} + +// AgentProposalEntry is a pending proposal for the API. +type AgentProposalEntry struct { + File string `json:"file"` + Title string `json:"title"` + Type string `json:"type"` + Urgency string `json:"urgency"` + Created string `json:"created"` +} + // ServeAgent runs the homeostatic agent loop inside cog serve. type ServeAgent struct { root string @@ -41,12 +101,203 @@ type ServeAgent struct { harness *AgentHarness bus *busSessionManager stopCh chan struct{} + wakeCh chan struct{} // event-driven wake signal (buffered 1) cancel context.CancelFunc wg sync.WaitGroup - // Metrics - lastRun time.Time - cycleCount int64 + // Metrics (read via Status()) + mu sync.RWMutex + startedAt time.Time + lastRun time.Time + cycleCount int64 + lastAction string + lastUrgency float64 + lastReason string + lastDurMs int64 + + // Decomposition-fed rolling memory (the first hypercycle wire) + cycleMemory *agentCycleMemory + lastObservation string // cached for decomposition context + + // Activity tracking for cheap checks gate and delta computation + lastRegistrySnapshot map[string]int64 // busID → LastEventSeq at last cycle + lastGitFileCount int // modified file count at last cycle + lastCoherenceOK bool // coherence status at last cycle + lastProposalCount int // pending proposal count at last cycle + + // Run awareness — prevents overlapping cycles + running int32 // atomic: 1 if a cycle is in progress, 0 otherwise +} + +// Status returns the current agent loop status for the API. +func (sa *ServeAgent) Status() AgentStatusResponse { + sa.mu.RLock() + defer sa.mu.RUnlock() + + uptime := time.Since(sa.startedAt) + resp := AgentStatusResponse{ + Alive: true, + Running: atomic.LoadInt32(&sa.running) == 1, + Uptime: agentFormatDuration(uptime), + UptimeSec: int64(uptime.Seconds()), + CycleCount: sa.cycleCount, + LastAction: sa.lastAction, + LastUrgency: sa.lastUrgency, + LastReason: sa.lastReason, + LastDurMs: sa.lastDurMs, + Interval: sa.interval.String(), + Model: sa.harness.model, + } + if !sa.lastRun.IsZero() { + resp.LastCycle = sa.lastRun.Format(time.RFC3339) + } + + // Enrich with activity, memory, proposals, and inbox (best-effort, outside lock) + sa.mu.RUnlock() + resp.Activity = sa.getActivityForAPI() + resp.Memory = sa.getMemoryForAPI() + resp.Proposals = sa.getProposalsForAPI() + resp.Inbox = linkfeed.BuildInboxSummaryForAPI(sa.root) + sa.mu.RLock() // re-acquire for deferred unlock + + return resp +} + +// getActivityForAPI computes the activity summary for the status API. +func (sa *ServeAgent) getActivityForAPI() *AgentActivitySummary { + if sa.bus == nil { + return nil + } + registry := sa.bus.loadRegistry() + if len(registry) == 0 { + return nil + } + + now := time.Now() + summary := &AgentActivitySummary{UserPresence: "unknown"} + + apiBusExclude := map[string]bool{ + "bus_chat_system_capabilities": true, + "bus_chat_http": true, + "bus_agent_harness": true, + "bus_index": true, + } + var userEventTime time.Time + + for _, entry := range registry { + seq := int64(entry.LastEventSeq) + prevSeq := sa.lastRegistrySnapshot[entry.BusID] + delta := seq - prevSeq + if delta < 0 { + delta = 0 + } + + bid := entry.BusID + isSystem := apiBusExclude[bid] + + if delta > 0 && !isSystem { + summary.TotalEventDelta += delta + if delta > summary.HottestDelta { + summary.HottestDelta = delta + summary.HottestBus = bid + if len(summary.HottestBus) > 40 { + summary.HottestBus = summary.HottestBus[:37] + "..." + } + } + } + + if !isSystem && entry.LastEventAt != "" { + if t, err := time.Parse(time.RFC3339Nano, entry.LastEventAt); err == nil { + if t.After(userEventTime) { + userEventTime = t + } + } + } + + if strings.HasPrefix(bid, "claude-code-") && delta > 0 { + summary.ClaudeCodeActive++ + summary.ClaudeCodeEvents += delta + } + } + + if !userEventTime.IsZero() { + ago := now.Sub(userEventTime) + summary.UserLastEventAgo = formatAgo(ago) + if ago < 5*time.Minute { + summary.UserPresence = "active" + } else if ago < 30*time.Minute { + summary.UserPresence = "recent" + } else { + summary.UserPresence = "idle" + } + } + + return summary +} + +// getMemoryForAPI returns the rolling memory entries for the status API. +func (sa *ServeAgent) getMemoryForAPI() []AgentMemoryEntry { + if sa.cycleMemory == nil { + return nil + } + entries := sa.cycleMemory.recent(maxRollingMemory) + if len(entries) == 0 { + return nil + } + now := time.Now() + result := make([]AgentMemoryEntry, len(entries)) + for i, e := range entries { + result[i] = AgentMemoryEntry{ + Cycle: e.Cycle, + Action: e.Action, + Urgency: e.Urgency, + Sentence: e.Sentence, + Ago: formatAgo(now.Sub(e.Timestamp)), + } + } + return result +} + +// getProposalsForAPI returns pending proposals for the status API. +func (sa *ServeAgent) getProposalsForAPI() []AgentProposalEntry { + dir := filepath.Join(sa.root, proposalsDir) + dirEntries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var result []AgentProposalEntry + for _, e := range dirEntries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + content, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + contentStr := string(content) + if !strings.Contains(contentStr, "status: pending") { + continue + } + result = append(result, AgentProposalEntry{ + File: e.Name(), + Title: extractFMField(contentStr, "title"), + Type: extractFMField(contentStr, "type"), + Urgency: extractFMField(contentStr, "urgency"), + Created: extractFMField(contentStr, "created"), + }) + } + return result +} + +// agentFormatDuration returns a human-readable duration like "4h 23m". +func agentFormatDuration(d time.Duration) string { + d = d.Round(time.Minute) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + if h > 0 { + return fmt.Sprintf("%dh %dm", h, m) + } + return fmt.Sprintf("%dm", m) } // NewServeAgent creates an agent loop for the given workspace. @@ -80,12 +331,17 @@ func NewServeAgent(root string) *ServeAgent { }) RegisterCoreTools(harness, root) - return &ServeAgent{ - root: root, - interval: interval, - harness: harness, - stopCh: make(chan struct{}), + sa := &ServeAgent{ + root: root, + interval: interval, + harness: harness, + stopCh: make(chan struct{}), + wakeCh: make(chan struct{}, 1), // buffered 1 so non-blocking send works + cycleMemory: newAgentCycleMemory(maxRollingMemory), + lastRegistrySnapshot: make(map[string]int64), } + sa.loadCycleMemory() + return sa } // SetBus attaches a bus session manager for emitting agent events. @@ -97,45 +353,180 @@ func (sa *ServeAgent) SetBus(mgr *busSessionManager) { func (sa *ServeAgent) Start() error { log.Printf("[agent] starting homeostatic loop (interval=%s, model=%s)", sa.interval, sa.harness.model) + sa.mu.Lock() + sa.startedAt = time.Now() + sa.mu.Unlock() + + // Ensure the agent bus exists and is registered so events appear + // in /v1/bus/list and cross-bus queries. + if sa.bus != nil { + sa.ensureBus() + } + ctx, cancel := context.WithCancel(context.Background()) sa.cancel = cancel sa.wg.Add(1) go sa.runLoop(ctx) + // Watch proposals directory for file changes → wake agent + go sa.watchProposals() + + // Watch inbox/links/ for new links → wake agent + go sa.watchInboxLinks() + return nil } +// ensureBus creates the bus directory, events file, and registry entry +// for the agent harness bus if they don't already exist. +func (sa *ServeAgent) ensureBus() { + busDir := filepath.Join(sa.bus.busesDir(), agentBusID) + if err := os.MkdirAll(busDir, 0755); err != nil { + log.Printf("[agent] failed to create bus dir: %v", err) + return + } + eventsFile := filepath.Join(busDir, "events.jsonl") + if _, err := os.Stat(eventsFile); os.IsNotExist(err) { + f, err := os.Create(eventsFile) + if err != nil { + log.Printf("[agent] failed to create events file: %v", err) + return + } + f.Close() + } + if err := sa.bus.registerBus(agentBusID, "kernel:agent", "kernel:agent"); err != nil { + log.Printf("[agent] failed to register bus: %v", err) + } +} + // Stop signals the loop to stop and waits for completion. func (sa *ServeAgent) Stop() { sa.cancel() close(sa.stopCh) sa.wg.Wait() - log.Printf("[agent] stopped after %d cycles", atomic.LoadInt64(&sa.cycleCount)) + sa.mu.RLock() + count := sa.cycleCount + sa.mu.RUnlock() + log.Printf("[agent] stopped after %d cycles", count) +} + +// watchProposals uses fsnotify to watch the proposals directory and wake the +// agent when new proposals are created or modified. Debounces at 500ms. +func (sa *ServeAgent) watchProposals() { + dir := filepath.Join(sa.root, proposalsDir) + os.MkdirAll(dir, 0o755) + + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("[agent] proposals watcher failed: %v", err) + return + } + defer watcher.Close() + + if err := watcher.Add(dir); err != nil { + log.Printf("[agent] cannot watch proposals dir: %v", err) + return + } + + log.Printf("[agent] watching proposals directory: %s", dir) + + var debounceTimer *time.Timer + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Op&(fsnotify.Create|fsnotify.Write) != 0 { + // Debounce: 500ms + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.AfterFunc(500*time.Millisecond, func() { + log.Printf("[agent] proposals changed, waking agent") + sa.Wake() + }) + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Printf("[agent] proposals watcher error: %v", err) + case <-sa.stopCh: + return + } + } +} + +// Wake signals the agent to run a cycle immediately instead of waiting for the +// timer. Non-blocking: if a wake is already pending it's a no-op. +// Enforces a 30-second cooldown to prevent thrashing when the agent's own +// proposals trigger bus events that re-wake it immediately. +func (sa *ServeAgent) Wake() { + sa.mu.RLock() + lastRun := sa.lastRun + sa.mu.RUnlock() + if !lastRun.IsZero() && time.Since(lastRun) < 30*time.Second { + return // cooldown — too soon after last cycle + } + select { + case sa.wakeCh <- struct{}{}: + default: // already signaled + } } // runLoop is the main ticker loop with adaptive interval. // Starts at agentIntervalMin, doubles toward agentIntervalMax on consecutive // "sleep" assessments, resets to agentIntervalMin on any non-sleep action. +// +// The loop is resilient: panics in runCycle are recovered and logged, +// and the loop continues after a backoff delay. func (sa *ServeAgent) runLoop(ctx context.Context) { defer sa.wg.Done() consecutiveSleeps := 0 + var action string // Run initial cycle after a short delay (let the kernel fully initialize) select { case <-time.After(60 * time.Second): - action := sa.runCycle(ctx) - if action == "sleep" { - consecutiveSleeps++ - } else { - consecutiveSleeps = 0 - } + action = sa.safeCycle(ctx) + consecutiveSleeps = sa.updateSleepCount(action, consecutiveSleeps) + case <-sa.wakeCh: + log.Printf("[agent] woke by event (during init delay)") + action = sa.safeCycle(ctx) + consecutiveSleeps = sa.updateSleepCount(action, consecutiveSleeps) case <-sa.stopCh: return } for { + // Self-chaining: if last cycle was "execute" and inbox still has work, + // skip the sleep and immediately re-cycle. Cap at 5 consecutive chains + // to prevent runaway loops. + chainCount := 0 + const maxChains = 5 + for action != "sleep" && action != "error" && action != "skip" && chainCount < maxChains { + inbox := linkfeed.ScanInbox(sa.root) + if inbox.RawCount == 0 { + break + } + chainCount++ + log.Printf("[agent] self-chain %d/%d: %d raw inbox items remaining, continuing immediately", chainCount, maxChains, inbox.RawCount) + // Brief pause to let GPU cool and prevent tight-looping + select { + case <-time.After(10 * time.Second): + case <-sa.stopCh: + return + } + action = sa.safeCycle(ctx) + consecutiveSleeps = sa.updateSleepCount(action, consecutiveSleeps) + } + if chainCount > 0 { + log.Printf("[agent] self-chain complete: %d cycles chained", chainCount) + } + // Adaptive interval: double on each consecutive sleep, cap at max interval := sa.interval for i := 0; i < consecutiveSleeps && interval < agentIntervalMax; i++ { @@ -149,28 +540,264 @@ func (sa *ServeAgent) runLoop(ctx context.Context) { select { case <-time.After(interval): - action := sa.runCycle(ctx) - if action == "sleep" { - consecutiveSleeps++ - } else { - consecutiveSleeps = 0 - } + action = sa.safeCycle(ctx) + consecutiveSleeps = sa.updateSleepCount(action, consecutiveSleeps) + case <-sa.wakeCh: + log.Printf("[agent] woke by event") + action = sa.safeCycle(ctx) + consecutiveSleeps = sa.updateSleepCount(action, consecutiveSleeps) case <-sa.stopCh: return } } } +// safeCycle wraps runCycle with panic recovery and run-overlap guard. +func (sa *ServeAgent) safeCycle(ctx context.Context) (action string) { + // Prevent overlapping cycles + if !atomic.CompareAndSwapInt32(&sa.running, 0, 1) { + log.Printf("[agent] cycle skipped: already running") + return "skip" + } + defer atomic.StoreInt32(&sa.running, 0) + + defer func() { + if r := recover(); r != nil { + log.Printf("[agent] PANIC recovered in cycle: %v", r) + action = "error" + } + }() + return sa.runCycle(ctx) +} + +// updateSleepCount returns the new consecutive sleep counter. +// Errors count as sleeps — a failed cycle didn't do useful work, +// so the interval should keep doubling, not reset to minimum. +func (sa *ServeAgent) updateSleepCount(action string, current int) int { + if action == "sleep" || action == "error" { + return current + 1 + } + return 0 +} + +// publishUserTurnReply is the seam used by ensureUserTurnReply to actually +// emit a dashboard response. Tests override this to capture calls without +// standing up a live bus manager; production points it at the bus-publishing +// implementation in agent_bus_inlet.go. +var publishUserTurnReply = publishDashboardResponse + +// ensureUserTurnReply guarantees that *something* gets published to the +// dashboard response bus when a cycle consumed pending user message(s). +// +// This closes the BLOCKER from PR #7 review: the inlet drains the queue +// eagerly, so if the cycle errors during assessment, hits a sleep, or +// produces an empty execute result, the user turn would be silently lost +// without this guarantee. The chosen design (b-from-the-review): always +// publish; pick the most informative payload available; never drop. +// +// Skipped when: +// - pendingMsgs is empty (nothing to reply to). +// - The respond tool already published this turn (tracked atomically via +// respondInvokedSince) — re-publishing would double-reply. +// +// Otherwise picks payload by priority: +// 1. cycle error → "(cycle failed: )" so the user knows why +// 2. executeResult non-empty → the model's prose narration of the action +// (the original auto-fallback path; preserves existing UX) +// 3. else → "(no reply: )" so a sleep/empty cycle +// still acknowledges the user instead of vanishing +// +// Publication is best-effort and non-blocking (goroutine); failures only log. +func ensureUserTurnReply( + pendingMsgs []pendingUserMsg, + respondSnap uint64, + cycleErr error, + assessment *Assessment, + executeResult string, +) { + if len(pendingMsgs) == 0 { + return + } + if respondInvokedSince(respondSnap) { + log.Printf("[dashboard-inlet] auto-fallback suppressed: respond tool already published this turn") + return + } + + var pubText, reasoning string + switch { + case cycleErr != nil: + pubText = fmt.Sprintf("(cycle failed: %s)", cycleErr.Error()) + reasoning = "auto-fallback: cycle errored before reply" + case strings.TrimSpace(executeResult) != "": + pubText = strings.TrimSpace(executeResult) + reasoning = "auto-fallback: model did not invoke respond tool" + default: + action, reason := "unknown", "" + if assessment != nil { + action = assessment.Action + reason = assessment.Reason + } + if reason != "" { + pubText = fmt.Sprintf("(no reply: %s — %s)", action, reason) + } else { + pubText = fmt.Sprintf("(no reply: %s)", action) + } + reasoning = "auto-fallback: cycle produced no reply" + } + + if pubText == "" { + return + } + // Fan out across every unique session_id observed on the pending queue. + // One reply per distinct session so each tab/client that sent a message + // sees the response — not just whichever happened to be first. See the + // Codex re-review comment on PR #3 for the full rationale. + sessionIDs := uniqueUserMessageSessionIDs(pendingMsgs) + if len(sessionIDs) == 0 { + // Shouldn't happen given the len(pendingMsgs)==0 early-return above, + // but belt-and-braces: fall back to a single broadcast so the user + // isn't silently dropped. + sessionIDs = []string{""} + } + + for _, sid := range sessionIDs { + go func(text, reason, sid string) { + if _, err := publishUserTurnReply(text, reason, sid); err != nil { + log.Printf("[dashboard-inlet] auto-fallback publish failed (session=%q): %v", sid, err) + return + } + log.Printf("[dashboard-inlet] auto-fallback published agent response (%d chars, session=%q) on bus_dashboard_response", len(text), sid) + }(pubText, reasoning, sid) + } +} + // runCycle executes a single observe-assess-execute pass. // Returns the assessment action string for adaptive interval logic. func (sa *ServeAgent) runCycle(ctx context.Context) string { start := time.Now() - cycle := atomic.AddInt64(&sa.cycleCount, 1) + sa.mu.Lock() + sa.cycleCount++ + cycle := sa.cycleCount + sa.mu.Unlock() + + // Mint a fresh cycle-trace ID for this iteration so all downstream events + // (assessment + tool dispatches inside Execute) share the same cycle_id. + cycleID := uuid.NewString() + ctx = WithCycleID(ctx, cycleID) + + log.Printf("[agent] cycle %d: starting (trace=%s)", cycle, cycleID) + + // Drain any pending dashboard user messages the bus inlet has queued. + // Greedy semantics (v1): drained messages are consumed by this cycle even + // if the model doesn't call `respond`. The reply guarantee is upheld by + // the auto-fallback below, which always emits *something* whenever + // pendingMsgs is non-empty — even on cycle errors, sleep-action, or + // timeouts — so a consumed user turn never produces silence. + pendingMsgs := drainPendingUserMessages() + if len(pendingMsgs) > 0 { + log.Printf("[agent] cycle %s: observing %d pending user message(s)", cycleID, len(pendingMsgs)) + } + + // Stamp ctx with the originating session_ids so downstream publishers can + // route replies back to every originating client/tab — not just the first. + // Two keys land on ctx: + // - WithSessionIDs: the de-duped fan-out list (used by respond tool + + // auto-fallback to publish one reply per unique session). + // - WithSessionID: the first session_id, preserved for legacy readers + // that only consult the single-id key. + // Multi-session interleaving happens when multiple tabs/clients post + // messages between reconcile ticks; without fan-out, only one tab sees + // the reply and the rest wait forever (the BLOCKER Codex flagged). + turnSessionIDs := uniqueUserMessageSessionIDs(pendingMsgs) + if len(turnSessionIDs) > 0 { + ctx = WithSessionIDs(ctx, turnSessionIDs) + if turnSessionIDs[0] != "" { + ctx = WithSessionID(ctx, turnSessionIDs[0]) + } + } + + // Cheap checks gate: skip model call if nothing changed. Pending user + // messages always bypass the gate — a user is waiting for a reply. + if len(pendingMsgs) == 0 { + if skip, reason := sa.shouldSkipModel(); skip { + log.Printf("[agent] cycle %d: skipped (%s)", cycle, reason) - log.Printf("[agent] cycle %d: starting", cycle) + sa.mu.Lock() + sa.lastRun = time.Now() + sa.lastAction = "skip" + sa.lastUrgency = 0 + sa.lastReason = reason + sa.lastDurMs = 0 + sa.mu.Unlock() - // Build observation from workspace state - observation := sa.gatherObservation() + sa.emitEvent("agent.skip", map[string]interface{}{ + "cycle": cycle, + "reason": reason, + }) + + // Skips don't write to rolling memory — only real assessments do. + // This prevents error/skip noise from diluting the compressed narrative. + + return "sleep" + } + } + + // Build the baseline prose observation (git/coherence/activity/etc). + // This is still used as the decomposition context (sa.lastObservation). + proseObservation := sa.gatherObservation() + + // Fetch the foveated-context manifest via HTTP loopback. The anchor query + // is drawn from the first pending user message when present; otherwise + // a generic "current workspace state" prompt so the manifest still falls + // back to salience-only scoring and returns something useful. + // + // This is the substrate seam: the manifest already knows content hashes, + // tiers, and salience per source — we surface all of it as typed records + // so the Assess phase sees "what matters" as structured data rather than + // having to infer it from prose. + anchorPrompt := firstUserMessageText(pendingMsgs) + if anchorPrompt == "" { + anchorPrompt = "current workspace state" + } + manifest, mfErr := fetchFoveatedManifest(ctx, anchorPrompt) + if mfErr != nil { + // Non-fatal — the cycle still runs on the prose baseline + user msgs. + log.Printf("[agent] cycle %s: foveated manifest fetch failed: %v (falling back to prose-only observation)", cycleID, mfErr) + } + + // Build typed observation records. Pending user messages carry salience + // 1.0 (always highest); foveated blocks inherit their max source salience; + // the prose baseline is elided from the JSON envelope and attached after + // it as a low-salience `workspace_summary` section so the model gets both + // the substrate signal (top) and the narrative context (bottom). + records := buildObservationRecords( + pendingMsgs, + manifest, + "", // gitSummary — already in prose baseline + "", // coherenceSummary — already in prose baseline + "", // busSummary — already in prose baseline + ) + + anchor := "" + goal := "" + if manifest != nil { + anchor = manifest.Anchor + goal = manifest.Goal + } + recordsJSON := renderObservationRecords(records, anchor, goal) + + // Compose the final observation: typed records on top (substrate-grade + // signal, JSON), prose baseline below (legacy narrative). The JSON header + // is labeled so the model can anchor on it; deletion of the old + // `=== PRIORITY` prepend is intentional — salience is now carried by + // record.salience, not by English. + var obsBuf strings.Builder + obsBuf.WriteString("=== Observation Records (typed, salience-ordered) ===\n") + obsBuf.WriteString(recordsJSON) + obsBuf.WriteString("\n\n") + obsBuf.WriteString("=== Workspace Summary (prose, low-salience) ===\n") + obsBuf.WriteString(proseObservation) + observation := obsBuf.String() // System prompt: concise, no thinking tags (Gemma E4B doesn't need them). // JSON mode is enforced by the harness via response_format. @@ -178,18 +805,151 @@ func (sa *ServeAgent) runCycle(ctx context.Context) string { Respond ONLY with a JSON object. No markdown, no explanation, no thinking. -{"action": "", "reason": "", "urgency": <0.0-1.0>, "target": ""} +{"action": "", "reason": "", "urgency": <0.0-1.0>, "target": ""} Actions: -- sleep: nothing needs attention -- consolidate: organize memory, clean stale docs -- repair: fix coherence drift or broken state -- observe: gather more info before acting (use tools) -- escalate: beyond local capability, needs cloud model`, sa.root) +- sleep: nothing needs attention, rest until next cycle +- observe: gather more info using tools (memory_search, memory_read, workspace_status, coherence_check) +- propose: write a proposal using the propose tool — for suggesting new plans or changes +- execute: do approved work using your tools — enrich links, pull link feed, process inbox items. Choose this when you have an approved plan or known tasks to complete. +- escalate: something needs human or cloud-model attention + +You are an observer and advisor. Your proposals are staged in a directory — they do not modify anything. The user reviews them at their convenience. You cannot interrupt anyone. Act confidently. + +Rules: +- You may ONLY return one of these actions: sleep, observe, propose, execute, escalate. No other values. +- PRIORITY: If the inbox has raw items (Raw items > 0), choose "execute" and process them with enrich_link. This is your primary job right now. +- Only choose "propose" if you have a genuinely NEW idea. Do not re-propose plans about things you've already proposed. +- Look at your Recent Cycle Memory. If the last 3+ entries show the same action, you MUST pick a DIFFERENT one. +- After reading a proposal's content, acknowledge it with acknowledge_proposal so you don't re-process it. +- When nothing needs attention, sleep. Sleeping is good. + +You also have a wait tool available in the execute phase. Use it when an observation warrants no proposal, no bus event, and no further investigation — nothing needs doing right now. Prefer wait over fabricating plans or rationalizing in prose. It ends the current cycle cleanly.`, sa.root) + + // Run assessment phase (JSON mode) + assessment, err := sa.harness.Assess(ctx, systemPrompt, observation) + + // Hard loop breaker: if the model chose the same action 3+ times, + // override to a different action. E4B can reason about the rule + // but can't reliably follow it, so we enforce it in code. + if err == nil && sa.cycleMemory != nil { + recent := sa.cycleMemory.recent(3) + if len(recent) >= 3 { + allSame := recent[0].Action == recent[1].Action && recent[1].Action == recent[2].Action + if allSame && assessment.Action == recent[0].Action { + old := assessment.Action + switch old { + case "observe": + if sa.lastProposalCount > 0 { + assessment.Action = "execute" + assessment.Reason = fmt.Sprintf("Hard loop break: %s×3 → execute (approved work exists)", old) + } else { + assessment.Action = "sleep" + assessment.Reason = fmt.Sprintf("Hard loop break: %s×3 → sleep", old) + } + case "propose": + assessment.Action = "execute" + assessment.Reason = fmt.Sprintf("Hard loop break: %s×3 → execute (stop proposing, start doing)", old) + case "execute": + // Don't break execute chains if there's still inbox work + inbox := linkfeed.ScanInbox(sa.root) + if inbox.RawCount > 0 { + // Let it keep executing — the self-chain will handle it + log.Printf("[agent] cycle %d: hard loop break suppressed: execute×3 but %d raw inbox items remain", cycle, inbox.RawCount) + } else { + assessment.Action = "sleep" + assessment.Reason = fmt.Sprintf("Hard loop break: %s×3 → sleep (rest after work)", old) + } + default: + assessment.Action = "sleep" + assessment.Reason = fmt.Sprintf("Hard loop break: %s×3 → sleep", old) + } + log.Printf("[agent] cycle %d: hard loop break: %s → %s", cycle, old, assessment.Action) + } + } + } + + // Work nudge (runs LAST — overrides both LLM choice and hard loop breaker): + // If the inbox has raw items and no recent execute, force execute. + // This is the highest priority override because real work exists. + if err == nil && sa.cycleMemory != nil && assessment.Action != "execute" { + inbox := linkfeed.ScanInbox(sa.root) + if inbox.RawCount > 0 { + recent := sa.cycleMemory.recent(2) + noRecentExecute := true + for _, r := range recent { + if r.Action == "execute" { + noRecentExecute = false + break + } + } + if noRecentExecute && len(recent) >= 2 { + old := assessment.Action + assessment.Action = "execute" + assessment.Reason = fmt.Sprintf("Work nudge: %d raw inbox items waiting, overriding %s → execute", inbox.RawCount, old) + log.Printf("[agent] cycle %d: work nudge: %s → execute (%d raw inbox items)", cycle, old, inbox.RawCount) + } + } + } + + // Emit a cycle.assessment trace event as soon as the (possibly overridden) + // assessment is final. Mapping note: Assessment.Urgency → CycleEvent + // Confidence, Assessment.Reason → CycleEvent Rationale. Best-effort. + if err == nil && assessment != nil { + if ev, bErr := trace.NewAssessment( + engine.TraceIdentity(), + cycleID, + assessment.Action, + assessment.Urgency, + assessment.Reason, + ); bErr == nil { + emitCycleEvent(ev) + } + } + + // Snapshot the respond-tool invocation count before entering Execute so + // the auto-fallback publisher below can tell whether the model already + // called respond this turn. If it did, we must NOT also publish + // executeResult — that would double-reply on the dashboard bus. + respondSnap := respondInvokeSnapshot() + + var executeResult string + if err == nil && assessment.Action != "sleep" { + // Execute phase gets a different prompt that encourages tool chaining + executePrompt := fmt.Sprintf(`You are the CogOS kernel agent executing an action. Workspace: %s + +You decided: %s (reason: %s, target: %s) + +Now execute this using your tools. You may call MULTIPLE tools in sequence: +- Call read_proposal to read proposals, then call acknowledge_proposal to mark them processed +- Call propose to write new proposals in response to what you read +- Call memory_search to find docs, then call memory_read to read them +- Call coherence_check, then propose a fix if needed +- Call list_inbox to see raw inbox filenames, then call enrich_link with each filename to process them +- Call pull_link_feed to pull new links from Discord +- Call wait when nothing needs doing — no proposal, no bus event, no further investigation. Prefer wait over fabricating plans or rationalizing in prose. It ends the current cycle cleanly. - assessment, executeResult, err := sa.harness.RunCycle(ctx, systemPrompt, observation) +Do not just describe what you would do — actually call the tools. When you are finished acting, respond with a brief summary of what you did.`, sa.root, assessment.Action, assessment.Reason, assessment.Target) + + task := fmt.Sprintf("Execute: %s\nTarget: %s\nReason: %s", + assessment.Action, assessment.Target, assessment.Reason) + // Limit execute phase to 60 seconds to prevent GPU exhaustion + execCtx, execCancel := context.WithTimeout(ctx, 60*time.Second) + executeResult, _ = sa.harness.Execute(execCtx, executePrompt, task) + execCancel() + } duration := time.Since(start) + // Reply guarantee for consumed user turns. If pendingMsgs was non-empty, + // we owe the dashboard a reply — even on cycle errors, sleep-action, or + // timeouts. Run this BEFORE the err short-circuit below; otherwise an + // assessment error drains the user turn and silently drops the reply + // (the original BLOCKER from PR #7 review). + // + // Skip when respond already landed this turn (snapshot delta > 0): + // publishing again would double-reply on the dashboard bus. + ensureUserTurnReply(pendingMsgs, respondSnap, err, assessment, executeResult) + if err != nil { log.Printf("[agent] cycle %d: error: %v (%s)", cycle, err, duration.Round(time.Millisecond)) sa.emitEvent("agent.error", map[string]interface{}{ @@ -199,11 +959,23 @@ Actions: return "error" } + // Update status fields for the API + sa.mu.Lock() sa.lastRun = time.Now() + sa.cycleCount = cycle + sa.lastAction = assessment.Action + sa.lastUrgency = assessment.Urgency + sa.lastReason = assessment.Reason + sa.lastDurMs = duration.Milliseconds() + sa.mu.Unlock() log.Printf("[agent] cycle %d: action=%s urgency=%.1f reason=%q (%s)", cycle, assessment.Action, assessment.Urgency, assessment.Reason, duration.Round(time.Millisecond)) + if executeResult != "" { + log.Printf("[agent] cycle %d: execute result: %s", cycle, agentTruncate(executeResult, 500)) + } + sa.emitEvent("agent.cycle", map[string]interface{}{ "cycle": cycle, "action": assessment.Action, @@ -212,8 +984,12 @@ Actions: "target": assessment.Target, "duration_ms": duration.Milliseconds(), "executed": executeResult != "", + "result": agentTruncate(executeResult, 2000), }) + // Store full cycle trace to disk for dashboard display + sa.storeCycleTrace(cycle, assessment, observation, executeResult, duration) + if assessment.Action == "escalate" { log.Printf("[agent] cycle %d: escalation requested — %s (target: %s)", cycle, assessment.Reason, assessment.Target) @@ -224,12 +1000,22 @@ Actions: }) } + // Decompose the assessment into Tier 0 and feed into rolling memory. + // Runs asynchronously so it doesn't delay the next cycle. + // This is the first self-feeding wire in the CogOS Hypercycle: + // PRODUCE → DECOMPOSE → ABSORB (via gatherObservation on next cycle). + go sa.decomposeAndStore(ctx, cycle, assessment) + return assessment.Action } // gatherObservation builds a compact observation string from workspace state. +// Also updates the observation cache for the cheap checks gate. func (sa *ServeAgent) gatherObservation() string { var sb strings.Builder + var cachedGitCount int + var cachedCoherenceOK bool + var cachedProposalCount int sb.WriteString("=== Workspace Observation ===\n") sb.WriteString(fmt.Sprintf("Time: %s\n", time.Now().Format(time.RFC3339))) @@ -239,7 +1025,8 @@ func (sa *ServeAgent) gatherObservation() string { if status, err := runQuietCommand(sa.root, "git", "status", "--porcelain"); err == nil { lines := strings.Split(strings.TrimSpace(status), "\n") if len(lines) > 0 && lines[0] != "" { - sb.WriteString(fmt.Sprintf("Git: %d modified files\n", len(lines))) + cachedGitCount = len(lines) + sb.WriteString(fmt.Sprintf("Git: %d modified files\n", cachedGitCount)) } else { sb.WriteString("Git: clean\n") } @@ -247,29 +1034,281 @@ func (sa *ServeAgent) gatherObservation() string { // Recent memory activity if recent, err := runQuietCommand(sa.root, "./scripts/cog", "memory", "search", "--recent", "1h"); err == nil && recent != "" { - // Just note whether there was recent activity lines := strings.Split(strings.TrimSpace(recent), "\n") sb.WriteString(fmt.Sprintf("Memory: %d recent docs\n", len(lines))) } - // Coherence check + // Coherence check (truncate output — full drift reports can be 600KB+) if coh, err := runQuietCommand(sa.root, "./scripts/cog", "coherence", "check"); err == nil { if strings.Contains(coh, "coherent") { sb.WriteString("Coherence: OK\n") + cachedCoherenceOK = true } else { - sb.WriteString(fmt.Sprintf("Coherence: DRIFT — %s\n", strings.TrimSpace(coh))) + // Just note the drift; don't dump the full report into observation + lines := strings.Split(strings.TrimSpace(coh), "\n") + sb.WriteString(fmt.Sprintf("Coherence: DRIFT detected (%d lines in report). Use coherence_check tool for details.\n", len(lines))) + cachedCoherenceOK = false } } // Kernel uptime - sb.WriteString(fmt.Sprintf("Agent cycle: %d\n", atomic.LoadInt64(&sa.cycleCount)+1)) + sa.mu.RLock() + currentCycle := sa.cycleCount + sa.mu.RUnlock() + sb.WriteString(fmt.Sprintf("Agent cycle: %d\n", currentCycle+1)) if !sa.lastRun.IsZero() { sb.WriteString(fmt.Sprintf("Last cycle: %s ago\n", time.Since(sa.lastRun).Round(time.Second))) } + // Inject system activity summary from bus registry + if activity := sa.gatherActivitySummary(); activity != "" { + sb.WriteString(activity) + } + + // Inject rolling compressed memory from previous cycles (the hypercycle wire) + if sa.cycleMemory != nil { + if mem := sa.cycleMemory.formatForObservation(); mem != "" { + sb.WriteString(mem) + } + } + + // Inject pending proposals so the agent sees what it's already proposed + if proposals := sa.gatherPendingProposals(); proposals != "" { + sb.WriteString(proposals) + // Count proposals for cache + cachedProposalCount = strings.Count(proposals, "[") + } + + // Inject link feed status + sb.WriteString(sa.gatherLinkFeedStatus()) + + // Inject inbox summary + sb.WriteString(sa.gatherInboxSummary()) + + // Update observation cache for the cheap checks gate + sa.updateObservationCache(cachedGitCount, cachedCoherenceOK, cachedProposalCount) + + obs := sb.String() + sa.lastObservation = obs // cache for decomposition context + return obs +} + +// gatherActivitySummary reads the bus registry and computes an activity summary. +// Cost: ~1-2ms (single file read of registry.json). +func (sa *ServeAgent) gatherActivitySummary() string { + if sa.bus == nil { + return "" + } + + registry := sa.bus.loadRegistry() + if len(registry) == 0 { + return "" + } + + now := time.Now() + + // System buses that fire from the kernel itself — NOT user activity + systemBuses := map[string]bool{ + "bus_chat_system_capabilities": true, + "bus_chat_http": true, + "bus_agent_harness": true, + "bus_index": true, + } + + var ( + claudeCodeActive int + claudeCodeEvents int64 + chatActive int + mcpActive int + voiceActive int + totalDelta int64 + hottestBus string + hottestDelta int64 + userEventTime time.Time // only from user-initiated buses + newSnapshot = make(map[string]int64, len(registry)) + ) + + for _, entry := range registry { + seq := int64(entry.LastEventSeq) + newSnapshot[entry.BusID] = seq + + // Compute delta from last cycle + prevSeq, hasPrev := sa.lastRegistrySnapshot[entry.BusID] + delta := int64(0) + if hasPrev { + delta = seq - prevSeq + } + + bid := entry.BusID + isSystem := systemBuses[bid] + + // Only count non-system bus events in the activity delta + if delta > 0 && !isSystem { + totalDelta += delta + if delta > hottestDelta { + hottestDelta = delta + hottestBus = bid + } + } + + // Track user presence only from user-initiated buses (not kernel system buses) + if !isSystem && entry.LastEventAt != "" { + if t, err := time.Parse(time.RFC3339Nano, entry.LastEventAt); err == nil { + if t.After(userEventTime) { + userEventTime = t + } + } + } + + // Classify by prefix + switch { + case strings.HasPrefix(bid, "claude-code-"): + if delta > 0 { + claudeCodeActive++ + claudeCodeEvents += delta + } + case strings.HasPrefix(bid, "bus_chat_") && !isSystem: + if delta > 0 { + chatActive++ + } + case strings.HasPrefix(bid, "bus_mcp_"): + if delta > 0 { + mcpActive++ + } + case strings.Contains(bid, "voice"): + if delta > 0 { + voiceActive++ + } + } + } + + // Update snapshot for next cycle's delta + sa.lastRegistrySnapshot = newSnapshot + + // Build summary + var sb strings.Builder + sb.WriteString("\n=== System Activity (since last cycle) ===\n") + + // User presence + if !userEventTime.IsZero() { + ago := now.Sub(userEventTime).Round(time.Second) + if ago < 5*time.Minute { + sb.WriteString(fmt.Sprintf("User presence: active (last event %s ago)\n", ago)) + } else if ago < 30*time.Minute { + sb.WriteString(fmt.Sprintf("User presence: recent (last event %s ago)\n", formatAgo(ago))) + } else { + sb.WriteString(fmt.Sprintf("User presence: idle (last event %s ago)\n", formatAgo(ago))) + } + } else { + sb.WriteString("User presence: unknown\n") + } + + // Session counts + if claudeCodeActive > 0 { + sb.WriteString(fmt.Sprintf("Claude Code sessions: %d active (%d new events)\n", claudeCodeActive, claudeCodeEvents)) + } else { + sb.WriteString("Claude Code sessions: 0 active\n") + } + + if chatActive > 0 { + sb.WriteString(fmt.Sprintf("Chat buses: %d with activity\n", chatActive)) + } + if mcpActive > 0 { + sb.WriteString(fmt.Sprintf("MCP sessions: %d active\n", mcpActive)) + } + if voiceActive > 0 { + sb.WriteString(fmt.Sprintf("Voice sessions: %d active\n", voiceActive)) + } + + // Overall activity + sb.WriteString(fmt.Sprintf("Total event delta: %d events since last cycle\n", totalDelta)) + if hottestBus != "" && hottestDelta > 0 { + // Truncate long bus IDs for readability + displayBus := hottestBus + if len(displayBus) > 40 { + displayBus = displayBus[:37] + "..." + } + sb.WriteString(fmt.Sprintf("Hottest bus: %s (%d events)\n", displayBus, hottestDelta)) + } + return sb.String() } +// shouldSkipModel returns true if nothing has changed since the last cycle, +// meaning we can skip the expensive E4B call and just sleep. +// This is the "cheap checks first" gate from the OpenClaw community pattern. +func (sa *ServeAgent) shouldSkipModel() (skip bool, reason string) { + // Never skip the first few cycles — let the model establish a baseline + sa.mu.RLock() + cycles := sa.cycleCount + sa.mu.RUnlock() + if cycles < 3 { + return false, "" + } + + // Check 1: Rolling memory — was last action already "sleep"? + lastEntries := sa.cycleMemory.recent(1) + if len(lastEntries) == 0 || lastEntries[0].Action != "sleep" { + return false, "" + } + + // Check 2: Git status changed? + gitCount := 0 + if status, err := runQuietCommand(sa.root, "git", "status", "--porcelain"); err == nil { + lines := strings.Split(strings.TrimSpace(status), "\n") + if len(lines) > 0 && lines[0] != "" { + gitCount = len(lines) + } + } + if gitCount != sa.lastGitFileCount { + return false, "git status changed" + } + + // Check 3: Bus events since last cycle? + if sa.bus != nil { + registry := sa.bus.loadRegistry() + for _, entry := range registry { + prevSeq, hasPrev := sa.lastRegistrySnapshot[entry.BusID] + if hasPrev && int64(entry.LastEventSeq) > prevSeq { + // Ignore our own agent harness bus — our own events shouldn't wake us + if entry.BusID == agentBusID { + continue + } + return false, fmt.Sprintf("bus activity: %s", entry.BusID) + } + } + } + + // Check 4: Coherence drift? + if !sa.lastCoherenceOK { + return false, "coherence drift" + } + + // Check 5: New proposals to review? + proposalDir := filepath.Join(sa.root, proposalsDir) + if entries, err := os.ReadDir(proposalDir); err == nil { + count := 0 + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + count++ + } + } + if count != sa.lastProposalCount { + return false, "proposal count changed" + } + } + + // All checks passed — nothing changed + return true, "no changes since last cycle" +} + +// updateObservationCache stores current state for the next cycle's delta checks. +func (sa *ServeAgent) updateObservationCache(gitCount int, coherenceOK bool, proposalCount int) { + sa.lastGitFileCount = gitCount + sa.lastCoherenceOK = coherenceOK + sa.lastProposalCount = proposalCount +} + // emitEvent sends an event to the CogBus (best-effort). func (sa *ServeAgent) emitEvent(eventType string, payload map[string]interface{}) { if sa.bus == nil { @@ -280,6 +1319,187 @@ func (sa *ServeAgent) emitEvent(eventType string, payload map[string]interface{} } } +// gatherPendingProposals returns a summary of pending proposals for observation injection. +func (sa *ServeAgent) gatherPendingProposals() string { + dir := filepath.Join(sa.root, proposalsDir) + entries, err := os.ReadDir(dir) + if err != nil { + return "" + } + + var pending []string + var acknowledged, approved, rejected int + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + content, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + contentStr := string(content) + status := extractFMField(contentStr, "status") + switch status { + case "pending": + title := extractFMField(contentStr, "title") + pType := extractFMField(contentStr, "type") + pending = append(pending, fmt.Sprintf(" [%s] %s", pType, title)) + case "acknowledged": + acknowledged++ + case "approved": + approved++ + case "rejected": + rejected++ + } + } + + if len(pending) == 0 && acknowledged == 0 && approved == 0 && rejected == 0 { + return "" + } + + var sb strings.Builder + if len(pending) > 0 { + sb.WriteString(fmt.Sprintf("\n=== Pending Proposals (%d) ===\n%s\n", len(pending), strings.Join(pending, "\n"))) + } + if acknowledged > 0 || approved > 0 || rejected > 0 { + sb.WriteString(fmt.Sprintf("Previously processed: %d acknowledged, %d approved, %d rejected\n", acknowledged, approved, rejected)) + } + + return sb.String() +} + +// gatherLinkFeedStatus returns the link feed timing status for observation injection. +func (sa *ServeAgent) gatherLinkFeedStatus() string { + _, err := linkfeed.ReadDiscordAuth(sa.root) + if err != nil { + return "\n=== Link Feed ===\nNot configured (missing auth)\n" + } + + ago, err := linkfeed.LinkFeedLastPull(sa.root) + if err != nil { + return "\n=== Link Feed ===\nLast pull: never\n" + } + + var sb strings.Builder + sb.WriteString("\n=== Link Feed ===\n") + if ago > linkfeed.LinkFeedCheckInterval { + sb.WriteString(fmt.Sprintf("Last pull: %s ago (overdue)\n", formatAgo(ago))) + } else { + remaining := linkfeed.LinkFeedCheckInterval - ago + sb.WriteString(fmt.Sprintf("Last pull: %s ago (next in %s)\n", formatAgo(ago), formatAgo(remaining))) + } + return sb.String() +} + +// gatherInboxSummary returns the inbox status for observation injection. +func (sa *ServeAgent) gatherInboxSummary() string { + inbox := linkfeed.ScanInbox(sa.root) + if inbox.TotalCount == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString("\n=== Inbox ===\n") + if inbox.RawCount > 0 { + sb.WriteString(fmt.Sprintf("Raw items: %d new links awaiting enrichment\n", inbox.RawCount)) + for _, f := range inbox.NewestRaw { + sb.WriteString(fmt.Sprintf(" - %s\n", f)) + } + } + sb.WriteString(fmt.Sprintf("Enriched: %d | Failed: %d | Total: %d\n", inbox.EnrichedCount, inbox.FailedCount, inbox.TotalCount)) + return sb.String() +} + +// watchInboxLinks uses fsnotify to watch the inbox/links/ directory and wake +// the agent when new links arrive. Runs alongside watchProposals. +func (sa *ServeAgent) watchInboxLinks() { + dir := filepath.Join(sa.root, linkfeed.InboxLinksRelPath) + os.MkdirAll(dir, 0o755) + + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("[agent] inbox watcher failed: %v", err) + return + } + defer watcher.Close() + + if err := watcher.Add(dir); err != nil { + log.Printf("[agent] cannot watch inbox dir: %v", err) + return + } + + log.Printf("[agent] watching inbox directory: %s", dir) + + var debounceTimer *time.Timer + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Op&(fsnotify.Create|fsnotify.Write) != 0 { + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.AfterFunc(2*time.Second, func() { + log.Printf("[agent] inbox changed, waking agent") + sa.Wake() + }) + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Printf("[agent] inbox watcher error: %v", err) + case <-sa.stopCh: + return + } + } +} + +// handleAgentStatus serves GET /v1/agent/status. +func (s *serveServer) handleAgentStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if s.agent == nil { + json.NewEncoder(w).Encode(AgentStatusResponse{Alive: false, Model: "none"}) + return + } + json.NewEncoder(w).Encode(s.agent.Status()) +} + +// handleAgentTraces serves GET /v1/agent/traces — returns recent cycle traces. +func (s *serveServer) handleAgentTraces(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if s.agent == nil { + json.NewEncoder(w).Encode([]cycleTrace{}) + return + } + traceFile := filepath.Join(s.agent.root, ".cog", ".state", "agent", "cycle-traces.json") + data, err := os.ReadFile(traceFile) + if err != nil { + json.NewEncoder(w).Encode([]cycleTrace{}) + return + } + w.Write(data) +} + +// handleAgentTrigger serves POST /v1/agent/trigger — manually triggers one cycle. +func (s *serveServer) handleAgentTrigger(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if s.agent == nil { + w.WriteHeader(503) + json.NewEncoder(w).Encode(map[string]string{"error": "agent not running"}) + return + } + if atomic.LoadInt32(&s.agent.running) == 1 { + w.WriteHeader(409) + json.NewEncoder(w).Encode(map[string]string{"status": "already_running"}) + return + } + go s.agent.safeCycle(context.Background()) + json.NewEncoder(w).Encode(map[string]string{"status": "triggered"}) +} + // runQuietCommand runs a command and returns stdout, suppressing stderr. func runQuietCommand(dir string, name string, args ...string) (string, error) { cmd := exec.CommandContext(context.Background(), name, args...) diff --git a/agent_tools.go b/agent_tools.go index d7b36d9..6b47a4e 100644 --- a/agent_tools.go +++ b/agent_tools.go @@ -13,17 +13,66 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/cogos-dev/cogos/internal/linkfeed" ) +// linkfeedHarnessAdapter adapts *AgentHarness to linkfeed.Harness. +// The adapter flattens linkfeed's RegisterTool(name, description, params, fn) +// surface into main's RegisterTool(ToolDefinition, ToolFunc) surface, so +// linkfeed stays a leaf package that does not import main's types. +type linkfeedHarnessAdapter struct{ h *AgentHarness } + +func (a linkfeedHarnessAdapter) RegisterTool( + name, description string, + parameters json.RawMessage, + fn func(ctx context.Context, args json.RawMessage) (json.RawMessage, error), +) { + a.h.RegisterTool(ToolDefinition{ + Type: "function", + Function: ToolFunction{ + Name: name, + Description: description, + Parameters: parameters, + }, + }, ToolFunc(fn)) +} + +func (a linkfeedHarnessAdapter) GenerateJSON(ctx context.Context, systemPrompt, userPrompt string) (string, error) { + return a.h.GenerateJSON(ctx, systemPrompt, userPrompt) +} + // RegisterCoreTools adds the standard kernel tools to the harness. // workspaceRoot is the absolute path to the .cog workspace. func RegisterCoreTools(h *AgentHarness, workspaceRoot string) { + // Read-only observation tools h.RegisterTool(memorySearchDef(), newMemorySearchFunc(workspaceRoot)) h.RegisterTool(memoryReadDef(), newMemoryReadFunc(workspaceRoot)) - h.RegisterTool(memoryWriteDef(), newMemoryWriteFunc(workspaceRoot)) h.RegisterTool(coherenceCheckDef(), newCoherenceCheckFunc(workspaceRoot)) - h.RegisterTool(busEmitDef(), newBusEmitFunc(workspaceRoot)) h.RegisterTool(workspaceStatusDef(), newWorkspaceStatusFunc(workspaceRoot)) + + // Sanctioned no-op — lets the agent exit the tool loop cleanly + // when nothing warrants action. + RegisterWaitTool(h, workspaceRoot) + + // Dashboard response — lets the metabolic cycle's agent reply to + // user_message events observed via agent_bus_inlet.go. Safe to + // register unconditionally: the tool returns a non-fatal error at + // invocation time if the dashboard inlet hasn't been installed. + RegisterRespondTool(h) + + // Propose-only write tools (no direct memory modification) + RegisterProposalTools(h, workspaceRoot) + + // Bus events (observable side-effects only) + h.RegisterTool(busEmitDef(), newBusEmitFunc(workspaceRoot)) + + // Link feed tools (Discord pull + enrichment) + linkfeed.RegisterLinkFeedTools(linkfeedHarnessAdapter{h: h}, workspaceRoot) + + // NOTE: memory_write is deliberately excluded. + // The agent proposes changes via the propose tool; a human or + // cloud-tier agent authorizes them before they take effect. } // --- memory_search --- @@ -164,7 +213,21 @@ func coherenceCheckDef() ToolDefinition { func newCoherenceCheckFunc(root string) ToolFunc { return func(ctx context.Context, _ json.RawMessage) (json.RawMessage, error) { - return runCogCommand(ctx, root, "coherence", "check") + result, err := runCogCommand(ctx, root, "coherence", "check") + if err != nil { + return result, err + } + // Truncate very large drift reports to prevent context window explosion. + // Full coherence output can be 600KB+ when many files are in drift. + const maxBytes = 4096 + if len(result) > maxBytes { + truncated := string(result[:maxBytes]) + return json.Marshal(map[string]string{ + "output": truncated, + "truncated": fmt.Sprintf("Output truncated from %d to %d bytes. Run `cog coherence check` for full report.", len(result), maxBytes), + }) + } + return result, nil } } diff --git a/agent_tools_propose.go b/agent_tools_propose.go new file mode 100644 index 0000000..d5c3e76 --- /dev/null +++ b/agent_tools_propose.go @@ -0,0 +1,401 @@ +// agent_tools_propose.go — Proposal-based tools for the agent harness. +// +// The agent can observe the workspace freely but cannot modify it directly. +// Instead it writes proposals to a staging area (.cog/.state/agent/proposals/) +// that require authorization from a human or cloud-tier agent before applying. +// +// This enforces the read-and-propose cycle: observe → assess → propose → sleep. +// The agent sees its own proposals on the next cycle and can build on them, +// revise them, or move on. + +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + proposalsDir = ".cog/.state/agent/proposals" +) + +// RegisterProposalTools replaces direct memory_write with gated proposal tools. +func RegisterProposalTools(h *AgentHarness, workspaceRoot string) { + h.RegisterTool(proposeDef(), newProposeFunc(workspaceRoot)) + h.RegisterTool(listProposalsDef(), newListProposalsFunc(workspaceRoot)) + h.RegisterTool(readProposalDef(), newReadProposalFunc(workspaceRoot)) + h.RegisterTool(acknowledgeProposalDef(), newAcknowledgeProposalFunc(workspaceRoot)) +} + +// --- propose --- + +func proposeDef() ToolDefinition { + return ToolDefinition{ + Type: "function", + Function: ToolFunction{ + Name: "propose", + Description: `Write a proposal for a workspace change. Proposals are staged — they do NOT modify the workspace directly. A human or authorized agent must approve them before they take effect. + +Use this to: +- Suggest memory document updates or new documents +- Propose code changes or refactors +- Record observations that should be acted on later +- Flag issues that need attention + +Each proposal gets a unique ID and is visible on subsequent cycles.`, + Parameters: json.RawMessage(`{ + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Proposal type: memory_update, memory_new, code_change, observation, escalation", + "enum": ["memory_update", "memory_new", "code_change", "observation", "escalation"] + }, + "target": { + "type": "string", + "description": "What this proposal targets (file path, memory path, or description)" + }, + "title": { + "type": "string", + "description": "Short title for the proposal" + }, + "body": { + "type": "string", + "description": "Full proposal content — the proposed change, observation, or escalation details" + }, + "urgency": { + "type": "number", + "description": "How urgent is this proposal (0.0 = informational, 1.0 = critical)" + } + }, + "required": ["type", "title", "body"] + }`), + }, + } +} + +func newProposeFunc(root string) ToolFunc { + return func(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var p struct { + Type string `json:"type"` + Target string `json:"target"` + Title string `json:"title"` + Body string `json:"body"` + Urgency float64 `json:"urgency"` + } + if err := json.Unmarshal(args, &p); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if p.Title == "" || p.Body == "" { + return json.Marshal(map[string]string{"error": "title and body are required"}) + } + if p.Type == "" { + p.Type = "observation" + } + + // Create proposals directory + dir := filepath.Join(root, proposalsDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return json.Marshal(map[string]string{"error": "cannot create proposals dir: " + err.Error()}) + } + + // Generate proposal ID from timestamp + now := time.Now() + id := now.Format("20060102-150405") + + // Build proposal as markdown with frontmatter + var sb strings.Builder + sb.WriteString("---\n") + sb.WriteString(fmt.Sprintf("id: %s\n", id)) + sb.WriteString(fmt.Sprintf("type: %s\n", p.Type)) + sb.WriteString(fmt.Sprintf("title: %q\n", p.Title)) + sb.WriteString(fmt.Sprintf("target: %q\n", p.Target)) + sb.WriteString(fmt.Sprintf("urgency: %.1f\n", p.Urgency)) + sb.WriteString(fmt.Sprintf("created: %s\n", now.Format(time.RFC3339))) + sb.WriteString("status: pending\n") + sb.WriteString("---\n\n") + sb.WriteString(fmt.Sprintf("# %s\n\n", p.Title)) + sb.WriteString(p.Body) + sb.WriteString("\n") + + filename := fmt.Sprintf("%s-%s.md", id, sanitizeFilename(p.Title)) + path := filepath.Join(dir, filename) + + if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil { + return json.Marshal(map[string]string{"error": "write proposal: " + err.Error()}) + } + + return json.Marshal(map[string]string{ + "status": "proposed", + "proposal_id": id, + "path": path, + "message": "Proposal staged. It will be visible on your next cycle and requires authorization to apply.", + }) + } +} + +// --- list_proposals --- + +func listProposalsDef() ToolDefinition { + return ToolDefinition{ + Type: "function", + Function: ToolFunction{ + Name: "list_proposals", + Description: "List all pending proposals you have made. Use this to see what you've already proposed so you don't repeat yourself.", + Parameters: json.RawMessage(`{ + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Filter by status: pending, approved, rejected, all (default: pending)", + "enum": ["pending", "approved", "rejected", "all"] + } + } + }`), + }, + } +} + +func newListProposalsFunc(root string) ToolFunc { + return func(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var p struct { + Status string `json:"status"` + } + if args != nil { + json.Unmarshal(args, &p) + } + if p.Status == "" { + p.Status = "pending" + } + + dir := filepath.Join(root, proposalsDir) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return json.Marshal(map[string]interface{}{"proposals": []string{}, "count": 0}) + } + return nil, err + } + + var proposals []map[string]string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + + // Quick read to check status + content, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + + contentStr := string(content) + if p.Status != "all" && !strings.Contains(contentStr, "status: "+p.Status) { + continue + } + + // Extract title and type from frontmatter + title := extractFMField(contentStr, "title") + pType := extractFMField(contentStr, "type") + urgency := extractFMField(contentStr, "urgency") + created := extractFMField(contentStr, "created") + + proposals = append(proposals, map[string]string{ + "file": e.Name(), + "title": title, + "type": pType, + "urgency": urgency, + "created": created, + }) + } + + return json.Marshal(map[string]interface{}{ + "proposals": proposals, + "count": len(proposals), + }) + } +} + +// --- read_proposal --- + +func readProposalDef() ToolDefinition { + return ToolDefinition{ + Type: "function", + Function: ToolFunction{ + Name: "read_proposal", + Description: "Read pending proposals. With no arguments, returns ALL pending proposals with full content. With a filename, returns that specific proposal.", + Parameters: json.RawMessage(`{ + "type": "object", + "properties": { + "filename": {"type": "string", "description": "Optional: specific proposal filename. If omitted, returns all pending proposals."} + } + }`), + }, + } +} + +func newReadProposalFunc(root string) ToolFunc { + return func(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var p struct { + Filename string `json:"filename"` + } + if args != nil { + json.Unmarshal(args, &p) + } + + // If a specific filename is given, read just that one + if p.Filename != "" { + path := filepath.Join(root, proposalsDir, filepath.Base(p.Filename)) + content, err := os.ReadFile(path) + if err != nil { + return json.Marshal(map[string]string{"error": "not found: " + err.Error()}) + } + return json.Marshal(map[string]string{"content": string(content)}) + } + + // No filename — return ALL pending proposals with full content + dir := filepath.Join(root, proposalsDir) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return json.Marshal(map[string]interface{}{"proposals": []string{}, "count": 0}) + } + return nil, err + } + + var proposals []map[string]string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + content, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + contentStr := string(content) + if !strings.Contains(contentStr, "status: pending") { + continue + } + proposals = append(proposals, map[string]string{ + "file": e.Name(), + "content": contentStr, + }) + } + + return json.Marshal(map[string]interface{}{ + "proposals": proposals, + "count": len(proposals), + }) + } +} + +// --- acknowledge_proposal --- + +func acknowledgeProposalDef() ToolDefinition { + return ToolDefinition{ + Type: "function", + Function: ToolFunction{ + Name: "acknowledge_proposal", + Description: "Mark a proposal as processed. Use after reading and noting a proposal's content. Prevents re-reading on future cycles.", + Parameters: json.RawMessage(`{ + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "The proposal filename to acknowledge" + }, + "status": { + "type": "string", + "description": "New status for the proposal", + "enum": ["acknowledged", "approved", "rejected"] + }, + "note": { + "type": "string", + "description": "Optional note to append to the proposal" + } + }, + "required": ["filename", "status"] + }`), + }, + } +} + +func newAcknowledgeProposalFunc(root string) ToolFunc { + return func(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var p struct { + Filename string `json:"filename"` + Status string `json:"status"` + Note string `json:"note"` + } + if err := json.Unmarshal(args, &p); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if p.Filename == "" || p.Status == "" { + return json.Marshal(map[string]string{"error": "filename and status are required"}) + } + validStatus := map[string]bool{"acknowledged": true, "approved": true, "rejected": true} + if !validStatus[p.Status] { + return json.Marshal(map[string]string{"error": "status must be acknowledged, approved, or rejected"}) + } + + path := filepath.Join(root, proposalsDir, filepath.Base(p.Filename)) + content, err := os.ReadFile(path) + if err != nil { + return json.Marshal(map[string]string{"error": "not found: " + err.Error()}) + } + + contentStr := string(content) + contentStr = strings.Replace(contentStr, "status: pending", "status: "+p.Status, 1) + + if p.Note != "" { + contentStr += "\n\n## Operator Note\n" + p.Note + "\n" + } + + if err := os.WriteFile(path, []byte(contentStr), 0o644); err != nil { + return json.Marshal(map[string]string{"error": "write failed: " + err.Error()}) + } + + return json.Marshal(map[string]string{ + "status": p.Status, + "file": p.Filename, + "message": fmt.Sprintf("Proposal marked as %s.", p.Status), + }) + } +} + +// --- Helpers --- + +// sanitizeFilename makes a string safe for use as a filename. +func sanitizeFilename(s string) string { + s = strings.ToLower(s) + s = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + if r == ' ' { + return '-' + } + return -1 + }, s) + if len(s) > 50 { + s = s[:50] + } + return s +} + +// extractFMField pulls a simple field value from YAML frontmatter. +func extractFMField(content, field string) string { + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, field+":") { + val := strings.TrimSpace(strings.TrimPrefix(line, field+":")) + val = strings.Trim(val, `"`) + return val + } + } + return "" +} diff --git a/agent_tools_respond.go b/agent_tools_respond.go new file mode 100644 index 0000000..5b5082d --- /dev/null +++ b/agent_tools_respond.go @@ -0,0 +1,167 @@ +// agent_tools_respond.go — Dashboard response tool for the agent harness. +// +// Paired with agent_bus_inlet.go: the inlet feeds dashboard user messages +// into the metabolic cycle, and this tool lets the cycle's agent reply. +// +// The respond tool publishes a structured agent_response payload onto +// bus_dashboard_response, where Mod³ subscribes via /v1/events/stream. +// +// Additive only: the standard RegisterCoreTools flow keeps its current +// behaviour; RegisterRespondTool is a separate call site so deployments that +// don't want the dashboard channel can skip it. + +package main + +import ( + "context" + "encoding/json" + "errors" + "sync/atomic" +) + +// respondToolName is the canonical name the Execute loop watches for. +const respondToolName = "respond" + +// errDashboardNotInstalled is returned when the respond tool is invoked +// before InstallDashboardInlet has wired a bus manager. +var errDashboardNotInstalled = errors.New("dashboard inlet not installed: call InstallDashboardInlet before using the respond tool") + +// respondInvokeCount is incremented on every successful respond-tool +// dispatch. The agent cycle snapshots the value before Execute and compares +// after — a delta means `respond` landed this turn and the auto-fallback +// publisher must skip (otherwise the dashboard sees a double reply). +// +// Package-global and atomic so the tool func (a closure) can increment it +// without plumbing state through the harness registry. +var respondInvokeCount uint64 + +// respondPublish is the seam used by the respond tool to emit a reply. Tests +// swap this to capture fan-out without standing up a live bus manager; +// production points it at the bus-publishing implementation. Must be called +// per-session during fan-out so multi-session cycles reach every recipient. +var respondPublish = publishDashboardResponse + +// respondInvokeSnapshot returns the current respond-call counter. Pair with a +// later check `respondInvokedSince(snapshot)` around the Execute call. +func respondInvokeSnapshot() uint64 { + return atomic.LoadUint64(&respondInvokeCount) +} + +// respondInvokedSince reports whether respond was called since the given +// snapshot was taken. Used by the agent cycle to dedup the auto-fallback. +func respondInvokedSince(snapshot uint64) bool { + return atomic.LoadUint64(&respondInvokeCount) > snapshot +} + +// RegisterRespondTool adds the respond tool to the harness. Callers should +// invoke this after InstallDashboardInlet so the tool has a bus manager to +// publish through; if called earlier, the tool will simply return an error +// at invocation time rather than at registration. +func RegisterRespondTool(h *AgentHarness) { + if h == nil { + return + } + h.RegisterTool(respondDef(), newRespondFunc()) +} + +// respondDef returns the tool schema for `respond`. +func respondDef() ToolDefinition { + return ToolDefinition{ + Type: "function", + Function: ToolFunction{ + Name: respondToolName, + Description: `Send a response to the user in the current dashboard conversation. Use this after you have observed a user_message event and want to reply. The message is published on bus_dashboard_response for the Mod³ dashboard to render. Call at most once per user turn; use wait if no reply is warranted.`, + Parameters: json.RawMessage(`{ + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The reply text to show the user." + }, + "reasoning": { + "type": "string", + "description": "Optional internal reasoning/trace for auditing. Not shown to the user directly." + } + }, + "required": ["text"] + }`), + }, + } +} + +// newRespondFunc returns the ToolFunc for `respond`. +// +// On success: {"ok": true, "bytes": }. +// On failure: an error wrapping the underlying cause (bus manager missing, +// bus write failure, malformed args). Errors are returned rather than +// swallowed so the harness can surface them in its transcript. +func newRespondFunc() ToolFunc { + return func(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var p struct { + Text string `json:"text"` + Reasoning string `json:"reasoning"` + } + if len(args) > 0 { + if err := json.Unmarshal(args, &p); err != nil { + return nil, err + } + } + if p.Text == "" { + return json.Marshal(map[string]interface{}{ + "ok": false, + "error": "text is required", + }) + } + + // Fan out across every session_id observed on the cycle's pending + // queue. A cycle that drained N messages from N different tabs owes + // each of them a reply — without this loop, only the first session + // would ever hear back and the rest would wait forever. + // + // sessionIDsFromContext returns nil when the cycle wasn't wired for + // fan-out (single-message cycles, non-dashboard calls, tests); we + // fall back to the legacy single-id path and let publishDashboardResponse + // omit the field when the id is empty (broadcast). + sessionIDs := sessionIDsFromContext(ctx) + if len(sessionIDs) == 0 { + sessionIDs = []string{sessionIDFromContext(ctx)} + } + + var ( + totalBytes int + firstErr error + anySuccess bool + ) + for _, sid := range sessionIDs { + n, err := respondPublish(p.Text, p.Reasoning, sid) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + anySuccess = true + totalBytes += n + } + + if !anySuccess { + // All publishes failed — surface the first error and do NOT bump + // the invocation counter so the auto-fallback still fires. + return json.Marshal(map[string]interface{}{ + "ok": false, + "error": firstErr.Error(), + }) + } + + // At least one publish landed. Bump the counter exactly once per + // respond-tool invocation so the auto-fallback dedups correctly — + // N fan-out publishes still count as one tool call. + atomic.AddUint64(&respondInvokeCount, 1) + + return json.Marshal(map[string]interface{}{ + "ok": true, + "bytes": totalBytes, + "recipients": len(sessionIDs), + }) + } +} diff --git a/agent_tools_respond_test.go b/agent_tools_respond_test.go new file mode 100644 index 0000000..fa3c118 --- /dev/null +++ b/agent_tools_respond_test.go @@ -0,0 +1,171 @@ +// agent_tools_respond_test.go — Coverage for the respond tool's per-session +// fan-out contract. +// +// The respond tool must emit one publish per unique session_id observed on +// the cycle's pending queue (carried on ctx via WithSessionIDs). Without the +// fan-out, a cycle drained from multiple tabs would only reply on whichever +// session_id was first — the BLOCKER Codex flagged during sub-PR #3 review. + +package main + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" +) + +// respondSink captures respond-tool publish calls without a live bus. +// Goroutine-safe because the respond tool may run concurrently in real +// deployments, and tests should tolerate the same. +type respondSink struct { + mu sync.Mutex + calls []capturedReply + err error // if non-nil, publish returns this instead of capturing +} + +func (r *respondSink) publish(text, reasoning, sessionID string) (int, error) { + if r.err != nil { + return 0, r.err + } + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, capturedReply{text, reasoning, sessionID}) + return len(text), nil +} + +func (r *respondSink) snapshot() []capturedReply { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]capturedReply, len(r.calls)) + copy(out, r.calls) + return out +} + +// withRespondSink swaps respondPublish for the duration of fn, restoring the +// production publisher after. +func withRespondSink(sink *respondSink, fn func()) { + orig := respondPublish + respondPublish = sink.publish + defer func() { respondPublish = orig }() + fn() +} + +// callRespond drives the respond tool func synchronously with the given ctx +// and args payload. Returns the JSON result (as a decoded map) and any error. +func callRespond(t *testing.T, ctx context.Context, text string) map[string]interface{} { + t.Helper() + args, err := json.Marshal(map[string]string{"text": text}) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + out, err := newRespondFunc()(ctx, args) + if err != nil { + t.Fatalf("respond returned error: %v", err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(out, &decoded); err != nil { + t.Fatalf("decode result: %v (raw=%s)", err, string(out)) + } + return decoded +} + +func TestRespondTool_FanOutAcrossSessions(t *testing.T) { + sink := &respondSink{} + withRespondSink(sink, func() { + preSnap := respondInvokeSnapshot() + ctx := WithSessionIDs(context.Background(), []string{"sess-A", "sess-B"}) + + result := callRespond(t, ctx, "shared answer") + if ok, _ := result["ok"].(bool); !ok { + t.Fatalf("respond returned ok=false: %v", result) + } + if recips, _ := result["recipients"].(float64); int(recips) != 2 { + t.Errorf("recipients = %v, want 2", result["recipients"]) + } + + calls := sink.snapshot() + if !sameStringSet(sessionIDsOf(calls), []string{"sess-A", "sess-B"}) { + t.Errorf("recipients = %v, want {sess-A, sess-B}", sessionIDsOf(calls)) + } + for _, c := range calls { + if c.Text != "shared answer" { + t.Errorf("text = %q, want %q (payload must be identical per recipient)", c.Text, "shared answer") + } + } + + // Counter must bump exactly once per respond-tool invocation, even + // though we published twice — the counter gates the auto-fallback, + // and multi-session fan-out is still one tool call. + after := atomic.LoadUint64(&respondInvokeCount) + if after-preSnap != 1 { + t.Errorf("respondInvokeCount delta = %d, want 1", after-preSnap) + } + }) +} + +func TestRespondTool_LegacySingleSessionContext(t *testing.T) { + sink := &respondSink{} + withRespondSink(sink, func() { + preSnap := respondInvokeSnapshot() + // Legacy path: ctx has only WithSessionID, not WithSessionIDs. + ctx := WithSessionID(context.Background(), "sess-legacy") + + result := callRespond(t, ctx, "hi") + if ok, _ := result["ok"].(bool); !ok { + t.Fatalf("respond returned ok=false: %v", result) + } + calls := sink.snapshot() + if len(calls) != 1 { + t.Fatalf("got %d publishes, want 1", len(calls)) + } + if calls[0].SessionID != "sess-legacy" { + t.Errorf("session_id = %q, want sess-legacy", calls[0].SessionID) + } + if atomic.LoadUint64(&respondInvokeCount)-preSnap != 1 { + t.Errorf("counter should bump exactly once on legacy single-session path") + } + }) +} + +func TestRespondTool_NoContextSessionBroadcast(t *testing.T) { + // No session_id on ctx → legacy broadcast (publish with empty sid). + sink := &respondSink{} + withRespondSink(sink, func() { + preSnap := respondInvokeSnapshot() + result := callRespond(t, context.Background(), "hi") + if ok, _ := result["ok"].(bool); !ok { + t.Fatalf("respond returned ok=false: %v", result) + } + calls := sink.snapshot() + if len(calls) != 1 { + t.Fatalf("got %d publishes, want 1 (broadcast)", len(calls)) + } + if calls[0].SessionID != "" { + t.Errorf("session_id = %q, want empty (broadcast)", calls[0].SessionID) + } + if atomic.LoadUint64(&respondInvokeCount)-preSnap != 1 { + t.Errorf("counter must still bump for broadcast path") + } + }) +} + +func TestRespondTool_AllPublishesFailDoesNotBumpCounter(t *testing.T) { + // All fan-out publishes fail → counter must NOT bump; auto-fallback + // depends on the invariant "counter bump == dashboard heard us". + sink := &respondSink{err: errors.New("bus write failure")} + withRespondSink(sink, func() { + preSnap := respondInvokeSnapshot() + ctx := WithSessionIDs(context.Background(), []string{"sess-A", "sess-B"}) + result := callRespond(t, ctx, "will fail") + if ok, _ := result["ok"].(bool); ok { + t.Fatalf("respond should have returned ok=false on total failure: %v", result) + } + if atomic.LoadUint64(&respondInvokeCount) != preSnap { + t.Errorf("counter must not bump when every publish failed") + } + }) +} + diff --git a/agent_tools_wait.go b/agent_tools_wait.go new file mode 100644 index 0000000..bfa80ae --- /dev/null +++ b/agent_tools_wait.go @@ -0,0 +1,87 @@ +// agent_tools_wait.go — Sanctioned silent no-op for the agent harness. +// +// The `wait` tool gives the model an action other than rationalizing when +// an observation warrants no further work this cycle. Without it, the tool +// loop forces the model to either keep calling tools (fabricating work) +// or emit content (justifying in prose). Calling `wait` terminates the +// current Execute cycle cleanly and records the reason. +// +// Analogous to Poke's wait tool — addresses the justification-loop pathology +// documented in the corpus comparison (Gap 1). + +package main + +import ( + "context" + "encoding/json" + "log" +) + +// waitToolName is the canonical name the Execute loop watches for. +const waitToolName = "wait" + +// RegisterWaitTool adds the wait tool to the harness. +func RegisterWaitTool(h *AgentHarness, workspaceRoot string) { + h.RegisterTool(waitDef(), newWaitFunc(workspaceRoot)) +} + +// waitDef returns the tool schema for `wait`. +func waitDef() ToolDefinition { + return ToolDefinition{ + Type: "function", + Function: ToolFunction{ + Name: waitToolName, + Description: `Sanctioned no-op. Call this when the observation warrants no proposal, no bus event, and no further investigation — nothing needs doing right now. This ends the current execution cycle cleanly. Prefer this over fabricating work or rationalizing in prose when nothing genuinely needs to happen.`, + Parameters: json.RawMessage(`{ + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Brief reason for waiting (recorded to the agent log but not acted on)" + } + } + }`), + }, + } +} + +// newWaitFunc returns the tool function for `wait`. +// +// The workspaceRoot is accepted for symmetry with other Register*Tool helpers +// and future extension (e.g. dedicated wait log at .cog/run/agent-waits.jsonl); +// current implementation writes only to stderr via log.Printf, matching the +// existing harness logging pattern. +func newWaitFunc(_ string) ToolFunc { + return func(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var p struct { + Reason string `json:"reason"` + } + if args != nil { + _ = json.Unmarshal(args, &p) + } + + reason := p.Reason + if reason == "" { + reason = "(no reason given)" + } + log.Printf("[agent] wait: %s", reason) + + return json.Marshal(map[string]string{ + "status": "waiting", + "reason": reason, + }) + } +} + +// extractWaitReason parses the reason field from a tool result written by +// newWaitFunc. Used by Execute to surface the reason in its final return. +// Returns empty string if the JSON is malformed or the field is absent. +func extractWaitReason(result json.RawMessage) string { + var r struct { + Reason string `json:"reason"` + } + if err := json.Unmarshal(result, &r); err != nil { + return "" + } + return r.Reason +} diff --git a/agent_user_turn_reply_test.go b/agent_user_turn_reply_test.go new file mode 100644 index 0000000..7459a6b --- /dev/null +++ b/agent_user_turn_reply_test.go @@ -0,0 +1,372 @@ +// agent_user_turn_reply_test.go — Coverage for ensureUserTurnReply. +// +// The reply guarantee is the contract that a consumed user turn always +// produces *something* on bus_dashboard_response — even when the cycle +// errored, slept, or returned an empty execute result. This test isolates +// the helper from the bus by swapping publishUserTurnReply for a capture. + +package main + +import ( + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// captureSink intercepts publish calls and records them. Embeds a mutex so +// the goroutine ensureUserTurnReply spawns can write back safely. +type captureSink struct { + mu sync.Mutex + calls []capturedReply + done chan struct{} +} + +type capturedReply struct { + Text string + Reasoning string + SessionID string +} + +func newCaptureSink() *captureSink { + return &captureSink{done: make(chan struct{}, 4)} +} + +func (c *captureSink) publish(text, reasoning, sessionID string) (int, error) { + c.mu.Lock() + c.calls = append(c.calls, capturedReply{text, reasoning, sessionID}) + c.mu.Unlock() + c.done <- struct{}{} + return len(text), nil +} + +func (c *captureSink) waitOne(t *testing.T) capturedReply { + t.Helper() + select { + case <-c.done: + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for publish; captured %d so far", len(c.calls)) + } + c.mu.Lock() + defer c.mu.Unlock() + return c.calls[len(c.calls)-1] +} + +func (c *captureSink) callCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.calls) +} + +// withSink installs the capture in publishUserTurnReply for the duration of +// fn, restoring the production publisher after. +func withSink(sink *captureSink, fn func()) { + orig := publishUserTurnReply + publishUserTurnReply = sink.publish + defer func() { publishUserTurnReply = orig }() + fn() +} + +func userTurn(text, sessionID string) []pendingUserMsg { + return []pendingUserMsg{{Text: text, SessionID: sessionID, Ts: time.Now()}} +} + +func TestEnsureUserTurnReply_PublishesOnCycleError(t *testing.T) { + sink := newCaptureSink() + withSink(sink, func() { + ensureUserTurnReply( + userTurn("hello", "sess-A"), + respondInvokeSnapshot(), // fresh snapshot — respond must not appear to have landed + errors.New("ollama: context deadline exceeded"), + nil, // assessment is nil when assess errored + "", + ) + got := sink.waitOne(t) + if got.SessionID != "sess-A" { + t.Errorf("session_id = %q, want sess-A", got.SessionID) + } + if got.Text == "" { + t.Error("expected non-empty reply on cycle error") + } + // Must surface the error so the user knows why they got a non-answer. + if !strings.Contains(got.Text, "cycle failed") { + t.Errorf("reply text should mention cycle failure; got %q", got.Text) + } + }) +} + +func TestEnsureUserTurnReply_PublishesExecuteResult(t *testing.T) { + sink := newCaptureSink() + withSink(sink, func() { + ensureUserTurnReply( + userTurn("hi", "sess-B"), + respondInvokeSnapshot(), + nil, + &Assessment{Action: "execute", Reason: "process inbox"}, + " I processed 3 items. ", + ) + got := sink.waitOne(t) + if got.Text != "I processed 3 items." { + t.Errorf("text = %q, want trimmed execute result", got.Text) + } + if got.SessionID != "sess-B" { + t.Errorf("session_id = %q, want sess-B", got.SessionID) + } + }) +} + +func TestEnsureUserTurnReply_PublishesEmptyExecuteWithSleep(t *testing.T) { + sink := newCaptureSink() + withSink(sink, func() { + ensureUserTurnReply( + userTurn("hey", "sess-C"), + respondInvokeSnapshot(), + nil, + &Assessment{Action: "sleep", Reason: "nothing to do"}, + "", // sleep skips Execute → empty result + ) + got := sink.waitOne(t) + if got.Text == "" { + t.Fatal("expected synthesized reply for sleep + empty result") + } + if !strings.Contains(got.Text, "sleep") { + t.Errorf("synthesized reply should mention the action; got %q", got.Text) + } + }) +} + +func TestEnsureUserTurnReply_NoUserTurn_NoPublish(t *testing.T) { + sink := newCaptureSink() + withSink(sink, func() { + ensureUserTurnReply(nil, respondInvokeSnapshot(), nil, &Assessment{Action: "sleep"}, "") + // Tiny grace period in case a goroutine got scheduled. + select { + case <-sink.done: + t.Fatal("publish must not happen when there is no user turn") + case <-time.After(50 * time.Millisecond): + } + }) +} + +func TestEnsureUserTurnReply_RespondAlreadyInvoked_NoPublish(t *testing.T) { + // Snapshot before the increment so respondInvokedSince(snap) == true. + // respondInvokeCount is a package-global counter; we only ever bump it + // (counters are monotonic), which is safe for parallel tests since the + // snapshot taken here captures the pre-bump value for THIS test only. + snap := respondInvokeSnapshot() + atomic.AddUint64(&respondInvokeCount, 1) + + sink := newCaptureSink() + withSink(sink, func() { + ensureUserTurnReply( + userTurn("hi", "sess-D"), + snap, + nil, + &Assessment{Action: "execute", Reason: "ok"}, + "some result", + ) + select { + case <-sink.done: + t.Fatal("publish must be suppressed when respond tool already landed this turn") + case <-time.After(50 * time.Millisecond): + } + }) +} + +// waitN blocks until n publish calls have landed or the timeout expires. +// Returns the captured calls (stable order not guaranteed since they race). +func (c *captureSink) waitN(t *testing.T, n int) []capturedReply { + t.Helper() + deadline := time.After(2 * time.Second) + for c.callCount() < n { + select { + case <-c.done: + case <-deadline: + t.Fatalf("timed out waiting for %d publishes; captured %d", n, c.callCount()) + } + } + c.mu.Lock() + defer c.mu.Unlock() + out := make([]capturedReply, len(c.calls)) + copy(out, c.calls) + return out +} + +// sessionIDsOf extracts the session_id field from a list of captured replies. +// Used for set-comparison assertions that don't depend on goroutine order. +func sessionIDsOf(calls []capturedReply) []string { + out := make([]string, len(calls)) + for i, c := range calls { + out[i] = c.SessionID + } + return out +} + +// sameStringSet returns true if got and want contain the same strings, +// regardless of ordering. Used to assert fan-out recipients without being +// sensitive to which goroutine happened to land first. +func sameStringSet(got, want []string) bool { + if len(got) != len(want) { + return false + } + gotMap := make(map[string]int, len(got)) + for _, s := range got { + gotMap[s]++ + } + for _, s := range want { + if gotMap[s] == 0 { + return false + } + gotMap[s]-- + } + return true +} + +// TestEnsureUserTurnReply_FanOutTwoSessions: cycle drained two messages from +// two different sessions and the agent emitted ONE response (executeResult). +// Contract: one publish per unique session_id, same payload. +func TestEnsureUserTurnReply_FanOutTwoSessions(t *testing.T) { + sink := newCaptureSink() + withSink(sink, func() { + pending := []pendingUserMsg{ + {Text: "from A", SessionID: "sess-A", Ts: time.Now()}, + {Text: "from B", SessionID: "sess-B", Ts: time.Now()}, + } + ensureUserTurnReply( + pending, + respondInvokeSnapshot(), + nil, + &Assessment{Action: "execute", Reason: "ok"}, + "shared answer", + ) + calls := sink.waitN(t, 2) + if !sameStringSet(sessionIDsOf(calls), []string{"sess-A", "sess-B"}) { + t.Errorf("recipients = %v, want {sess-A, sess-B}", sessionIDsOf(calls)) + } + for _, c := range calls { + if c.Text != "shared answer" { + t.Errorf("text = %q, want %q (payload should be identical across sessions)", c.Text, "shared answer") + } + } + }) +} + +// TestEnsureUserTurnReply_FanOutSameSessionCollapses: two messages from the +// same session collapse to one publish. Guards against N-messages-N-replies +// regression when a single tab sends multiple messages in one cycle. +func TestEnsureUserTurnReply_FanOutSameSessionCollapses(t *testing.T) { + sink := newCaptureSink() + withSink(sink, func() { + pending := []pendingUserMsg{ + {Text: "msg 1", SessionID: "sess-X", Ts: time.Now()}, + {Text: "msg 2", SessionID: "sess-X", Ts: time.Now()}, + } + ensureUserTurnReply( + pending, + respondInvokeSnapshot(), + nil, + &Assessment{Action: "execute", Reason: "ok"}, + "ack", + ) + // Wait for the one expected call, then confirm no second arrives. + got := sink.waitOne(t) + if got.SessionID != "sess-X" { + t.Errorf("session_id = %q, want sess-X", got.SessionID) + } + select { + case <-sink.done: + t.Fatalf("unexpected second publish; calls=%d", sink.callCount()) + case <-time.After(50 * time.Millisecond): + } + }) +} + +// TestEnsureUserTurnReply_FanOutErrorMultiSession: cycle errored after draining +// messages from two sessions. Every session must still receive the failure +// notice — silence on one tab would be the original BLOCKER regression. +func TestEnsureUserTurnReply_FanOutErrorMultiSession(t *testing.T) { + sink := newCaptureSink() + withSink(sink, func() { + pending := []pendingUserMsg{ + {Text: "A", SessionID: "sess-A", Ts: time.Now()}, + {Text: "B", SessionID: "sess-B", Ts: time.Now()}, + } + ensureUserTurnReply( + pending, + respondInvokeSnapshot(), + errors.New("ollama: deadline"), + nil, + "", + ) + calls := sink.waitN(t, 2) + if !sameStringSet(sessionIDsOf(calls), []string{"sess-A", "sess-B"}) { + t.Errorf("recipients = %v, want {sess-A, sess-B}", sessionIDsOf(calls)) + } + for _, c := range calls { + if !strings.Contains(c.Text, "cycle failed") { + t.Errorf("reply text should mention cycle failure; got %q", c.Text) + } + } + }) +} + +// TestUniqueUserMessageSessionIDs covers the ordering + dedup + empty-id +// contract that the fan-out loop depends on. +func TestUniqueUserMessageSessionIDs(t *testing.T) { + cases := []struct { + name string + in []pendingUserMsg + want []string + }{ + { + name: "nil input", + in: nil, + want: nil, + }, + { + name: "single session preserved", + in: []pendingUserMsg{{SessionID: "a"}}, + want: []string{"a"}, + }, + { + name: "duplicates collapse, first-seen order preserved", + in: []pendingUserMsg{ + {SessionID: "a"}, + {SessionID: "b"}, + {SessionID: "a"}, + {SessionID: "c"}, + }, + want: []string{"a", "b", "c"}, + }, + { + name: "empty id collapses to one broadcast entry", + in: []pendingUserMsg{{SessionID: ""}, {SessionID: ""}}, + want: []string{""}, + }, + { + name: "mixed empty + named", + in: []pendingUserMsg{ + {SessionID: ""}, + {SessionID: "a"}, + {SessionID: ""}, + }, + want: []string{"", "a"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := uniqueUserMessageSessionIDs(tc.in) + if len(got) != len(tc.want) { + t.Fatalf("len=%d, want %d (got=%v)", len(got), len(tc.want), got) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i]) + } + } + }) + } +} + diff --git a/bus_session.go b/bus_session.go index a9bb928..b4b7d2f 100644 --- a/bus_session.go +++ b/bus_session.go @@ -125,6 +125,38 @@ func (m *busSessionManager) createChatBus(sessionID, origin string) (string, err return busID, nil } +// createMCPBus creates a new bus for an MCP HTTP session. +// The bus ID is derived from the session ID: bus_mcp_{sessionID}. +func (m *busSessionManager) createMCPBus(sessionID, origin string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + busID := fmt.Sprintf("bus_mcp_%s", sessionID) + + // Create bus directory + busDir := filepath.Join(m.busesDir(), busID) + if err := os.MkdirAll(busDir, 0755); err != nil { + return "", fmt.Errorf("create bus dir: %w", err) + } + + // Create events file if it doesn't exist + eventsFile := filepath.Join(busDir, "events.jsonl") + if _, err := os.Stat(eventsFile); os.IsNotExist(err) { + f, err := os.Create(eventsFile) + if err != nil { + return "", fmt.Errorf("create events file: %w", err) + } + f.Close() + } + + // Register bus in registry + if err := m.registerBus(busID, sessionID, origin); err != nil { + return "", fmt.Errorf("register bus: %w", err) + } + + return busID, nil +} + // registerBus adds or updates a bus entry in the registry. func (m *busSessionManager) registerBus(busID, sessionID, origin string) error { registry := m.loadRegistry() diff --git a/bus_tool.go b/bus_tool.go index 113517a..28af512 100644 --- a/bus_tool.go +++ b/bus_tool.go @@ -21,6 +21,10 @@ const ( BlockSystemStartup = "system.startup" BlockSystemShutdown = "system.shutdown" BlockSystemHealth = "system.health" + + // MCP session lifecycle + BlockMCPSessionInit = "mcp.session.init" + BlockMCPSessionEnd = "mcp.session.end" ) // ToolInvokePayload is the bus event payload for tool invocation requests. diff --git a/cmd_service.go b/cmd_service.go index 7e8d004..44b0984 100644 --- a/cmd_service.go +++ b/cmd_service.go @@ -19,6 +19,7 @@ import ( "io" "net/http" "os" + "os/exec" "os/signal" "strings" "time" @@ -78,21 +79,23 @@ func cmdServiceList(args []string) int { runtime := NewDockerClient("") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - runtimeAvailable := runtime.Ping(ctx) == nil - fmt.Printf("%-20s %-40s %-12s %-8s %s\n", "NAME", "IMAGE", "STATUS", "HEALTH", "PORTS") - fmt.Printf("%-20s %-40s %-12s %-8s %s\n", - strings.Repeat("─", 20), strings.Repeat("─", 40), + fmt.Printf("%-20s %-8s %-40s %-12s %-8s %s\n", "NAME", "MODE", "IMAGE / COMMAND", "STATUS", "HEALTH", "PORTS") + fmt.Printf("%-20s %-8s %-40s %-12s %-8s %s\n", + strings.Repeat("─", 20), strings.Repeat("─", 8), strings.Repeat("─", 40), strings.Repeat("─", 12), strings.Repeat("─", 8), strings.Repeat("─", 20)) for _, crd := range crds { + mode := serviceMode(&crd, runtimeAvailable) status := "not running" health := "—" ports := formatPorts(crd.Spec.Ports) - image := truncateString(crd.Spec.Image, 40) + var target string - if runtimeAvailable { + switch mode { + case modeDocker: + target = truncateString(crd.Spec.Image, 40) entry, _ := runtime.FindManagedContainer(ctx, crd.Metadata.Name) if entry != nil { status = entry.State @@ -100,12 +103,20 @@ func cmdServiceList(args []string) int { health = checkServiceHealth(ctx, &crd) } } - } else { - status = "runtime n/a" + case modeLocal: + target = truncateString(crd.Spec.Local.Command+" "+strings.Join(crd.Spec.Local.Args, " "), 40) + proc, _ := LocalStatus(root, crd.Metadata.Name) + if proc != nil && proc.Running { + status = fmt.Sprintf("pid %d", proc.PID) + health = checkServiceHealth(ctx, &crd) + } + case modeNone: + target = truncateString(crd.Spec.Image, 40) + status = "no executor" } - fmt.Printf("%-20s %-40s %-12s %-8s %s\n", - crd.Metadata.Name, image, status, health, ports) + fmt.Printf("%-20s %-8s %-40s %-12s %-8s %s\n", + crd.Metadata.Name, mode, target, status, health, ports) } return 0 @@ -274,9 +285,34 @@ func cmdServiceStart(args []string) int { runtime := NewDockerClient("") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() + runtimeAvailable := runtime.Ping(ctx) == nil - if err := runtime.Ping(ctx); err != nil { - fmt.Fprintf(os.Stderr, "Error: container runtime not available: %v\n", err) + // Dispatch to local runner for local-mode services. + if serviceMode(crd, runtimeAvailable) == modeLocal { + if proc, _ := LocalStatus(root, name); proc != nil && proc.Running { + fmt.Printf("Service %s is already running (pid %d)\n", name, proc.PID) + return 0 + } + proc, err := LocalStart(root, crd) + if err != nil { + fmt.Fprintf(os.Stderr, "Error starting local service: %v\n", err) + return 1 + } + fmt.Printf("Started %s as pid %d (logs: %s)\n", name, proc.PID, proc.LogPath) + if crd.Spec.Health.Endpoint != "" && crd.Spec.Health.Port > 0 { + fmt.Printf("Waiting for health check (localhost:%d%s)...\n", + crd.Spec.Health.Port, crd.Spec.Health.Endpoint) + if err := waitForHealth(ctx, crd); err != nil { + fmt.Fprintf(os.Stderr, "Warning: health check did not pass: %v\n", err) + } else { + fmt.Println("Health check passed.") + } + } + return 0 + } + + if !runtimeAvailable { + fmt.Fprintf(os.Stderr, "Error: container runtime not available and no spec.local defined\n") return 1 } @@ -363,8 +399,8 @@ func cmdServiceStop(args []string) int { } name := args[0] - // Validate service exists - if _, err := LoadServiceCRD(root, name); err != nil { + crd, err := LoadServiceCRD(root, name) + if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) return 1 } @@ -372,9 +408,25 @@ func cmdServiceStop(args []string) int { runtime := NewDockerClient("") ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() + runtimeAvailable := runtime.Ping(ctx) == nil - if err := runtime.Ping(ctx); err != nil { - fmt.Fprintf(os.Stderr, "Error: container runtime not available: %v\n", err) + if serviceMode(crd, runtimeAvailable) == modeLocal { + proc, _ := LocalStatus(root, name) + if proc == nil || !proc.Running { + fmt.Printf("Service %s is not running (local).\n", name) + return 0 + } + fmt.Printf("Stopping %s (pid %d)...\n", name, proc.PID) + if err := LocalStop(root, name); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + fmt.Printf("Service %s stopped.\n", name) + return 0 + } + + if !runtimeAvailable { + fmt.Fprintf(os.Stderr, "Error: container runtime not available and no spec.local defined\n") return 1 } @@ -435,7 +487,8 @@ func cmdServiceLogs(args []string) int { } name := remaining[0] - if _, err := LoadServiceCRD(root, name); err != nil { + crd, err := LoadServiceCRD(root, name) + if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) return 1 } @@ -444,6 +497,11 @@ func cmdServiceLogs(args []string) int { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + runtimeAvailable := runtime.Ping(ctx) == nil + + if serviceMode(crd, runtimeAvailable) == modeLocal { + return tailLocalLog(localLogPath(root, name), *follow, *tail) + } // Handle Ctrl-C gracefully sigCh := make(chan os.Signal, 1) @@ -457,8 +515,8 @@ func cmdServiceLogs(args []string) int { } }() - if err := runtime.Ping(ctx); err != nil { - fmt.Fprintf(os.Stderr, "Error: container runtime not available: %v\n", err) + if !runtimeAvailable { + fmt.Fprintf(os.Stderr, "Error: container runtime not available\n") return 1 } @@ -609,6 +667,33 @@ func waitForHealth(ctx context.Context, crd *ServiceCRD) error { } } +// tailLocalLog shells out to `tail` for the local-service log file. Keeps +// follow/tail semantics consistent with the docker path without reimplementing +// them. Returns 0 on clean exit or 1 on unexpected error. +func tailLocalLog(path string, follow bool, n string) int { + if _, err := os.Stat(path); err != nil { + fmt.Fprintf(os.Stderr, "Error: no log file at %s\n", path) + return 1 + } + args := []string{"-n", n} + if follow { + args = append(args, "-F") + } + args = append(args, path) + cmd := exec.Command("tail", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + // `tail -F` is cancelled by Ctrl-C via exit(130); don't surface as failure. + if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 130 { + return 0 + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + return 0 +} + // stripDockerLogHeaders removes the 8-byte Docker multiplexed stream headers // from log output. Each frame: [type(1)][0(3)][size_be32(4)][payload]. func stripDockerLogHeaders(data []byte) []byte { diff --git a/cog b/cog index 9850520..b384dd7 100755 Binary files a/cog and b/cog differ diff --git a/cog.go b/cog.go index 2e4f49f..d53bedb 100644 --- a/cog.go +++ b/cog.go @@ -30,6 +30,7 @@ import ( "gopkg.in/yaml.v3" + "github.com/cogos-dev/cogos/internal/workspace" "github.com/cogos-dev/cogos/pkg/coordination" ) @@ -1157,115 +1158,36 @@ func gitRoot() (string, error) { return strings.TrimSpace(string(out)), nil } -// workspaceCache caches the result of workspace resolution. -// ResolveWorkspace() is called 25+ times per process; caching avoids -// redundant config reads and git operations. -var workspaceCache struct { - once sync.Once - root string - source string - err error -} - -// ResolveWorkspace determines the workspace root based on precedence: -// 1. COG_ROOT environment variable (explicit) -// 2. COG_WORKSPACE env var (lookup in global config) -// 3. Local git repo detection (if inside a workspace) -// 4. current-workspace from ~/.cog/config -// Returns (workspaceRoot, source, error) where source describes how it was resolved. +// ResolveWorkspace is aliased to the internal/workspace package implementation. +// See internal/workspace/workspace.go for the resolution algorithm. The alias +// keeps the ~100 existing call sites compiling unchanged while providers can +// now import the workspace package directly. // -// Results are cached for the lifetime of the process since resolution is -// deterministic within a single invocation. -func ResolveWorkspace() (string, string, error) { - workspaceCache.once.Do(func() { - workspaceCache.root, workspaceCache.source, workspaceCache.err = resolveWorkspaceUncached() - }) - return workspaceCache.root, workspaceCache.source, workspaceCache.err -} - -// isRealWorkspace checks if dir has a .cog/ directory that looks like a real -// CogOS workspace (has config/ or mem/), not just a bare .cog/ with .state/ only -// (which submodules sometimes have). -func isRealWorkspace(dir string) bool { - cogDir := filepath.Join(dir, ".cog") - info, err := os.Stat(cogDir) - if err != nil || !info.IsDir() { - return false - } - // A real workspace has config/ subdirectory (not just mem/ or .state/) - if info, err := os.Stat(filepath.Join(cogDir, "config")); err == nil && info.IsDir() { - return true - } - return false -} +// Dependency wiring (LoadConfig, GitRoot) is installed in init() below. +var ResolveWorkspace = workspace.ResolveWorkspace -// resolveWorkspaceUncached implements the actual workspace resolution logic. -// Called once per process via ResolveWorkspace(). -func resolveWorkspaceUncached() (string, string, error) { - // 1. Explicit COG_ROOT (set by wrapper for --root flag) - if root := os.Getenv("COG_ROOT"); root != "" { - cogDir := filepath.Join(root, ".cog") - if info, err := os.Stat(cogDir); err == nil && info.IsDir() { - return root, "explicit", nil - } - return "", "", fmt.Errorf("COG_ROOT=%s is not a valid workspace (no .cog/ directory)", root) - } - - // 2. COG_WORKSPACE env var (lookup by name in global config) - // Graceful degradation: fall through to tier 3 if config fails or workspace not found - if wsName := os.Getenv("COG_WORKSPACE"); wsName != "" { - config, err := loadGlobalConfig() - if err == nil { // Only proceed if config loaded successfully - if ws, ok := config.Workspaces[wsName]; ok { - cogDir := filepath.Join(ws.Path, ".cog") - if _, err := os.Stat(cogDir); err != nil { - return "", "", fmt.Errorf("workspace '%s' at %s is invalid (no .cog/ directory)", wsName, ws.Path) - } - return ws.Path, "env", nil - } - } - // Fall through to tier 3 (local git detection) silently - } +// globalConfigAdapter adapts *GlobalConfig to workspace.ConfigProvider. +type globalConfigAdapter struct{ cfg *GlobalConfig } - // 3. Local git detection (if inside a workspace) - // Walk up through git roots — a submodule might have a bare .cog/ directory - // (with only .state/) but the real workspace is the parent repo with config/ and mem/. - if root, err := gitRoot(); err == nil { - if isRealWorkspace(root) { - return root, "local", nil - } - // If git root has a .cog/ but it's not a real workspace (e.g. submodule), - // check the parent directory's git root (the superproject). - parentDir := filepath.Dir(root) - if parentDir != root { // not filesystem root - // Walk up looking for a real workspace - for dir := parentDir; dir != "/" && dir != "."; dir = filepath.Dir(dir) { - if isRealWorkspace(dir) { - return dir, "local", nil - } - } - } - } +func (a globalConfigAdapter) CurrentWorkspace() string { return a.cfg.CurrentWorkspace } - // 4. Fall back to global current-workspace - config, err := loadGlobalConfig() - if err != nil { - return "", "", fmt.Errorf("failed to load global config: %w", err) +func (a globalConfigAdapter) WorkspacePath(name string) (string, bool) { + ws, ok := a.cfg.Workspaces[name] + if !ok || ws == nil { + return "", false } + return ws.Path, true +} - if config.CurrentWorkspace != "" { - if ws, ok := config.Workspaces[config.CurrentWorkspace]; ok { - cogDir := filepath.Join(ws.Path, ".cog") - if _, err := os.Stat(cogDir); err != nil { - return "", "", fmt.Errorf("workspace '%s' at %s is invalid (no .cog/ directory)", config.CurrentWorkspace, ws.Path) - } - return ws.Path, "global", nil +func init() { + workspace.LoadConfig = func() (workspace.ConfigProvider, error) { + cfg, err := loadGlobalConfig() + if err != nil { + return nil, err } - // Current workspace is set but doesn't exist in config - return "", "", fmt.Errorf("current workspace '%s' not found in config", config.CurrentWorkspace) + return globalConfigAdapter{cfg: cfg}, nil } - - return "", "", fmt.Errorf("no workspace found (run 'cog workspace add' or cd into a workspace)") + workspace.GitRoot = gitRoot } func gitTreeHash() (string, error) { @@ -5828,6 +5750,8 @@ func main() { code = cmdOCI(os.Args[2:]) case "service": code = cmdService(os.Args[2:]) + case "decompose": + code = cmdDecompose(os.Args[2:]) case "version", "-v", "--version": code = cmdVersion() case "info": diff --git a/component_wiring.go b/component_wiring.go new file mode 100644 index 0000000..8883f03 --- /dev/null +++ b/component_wiring.go @@ -0,0 +1,72 @@ +// component_wiring.go — DI wiring for the internal/providers/component package. +// +// The component provider lives in internal/providers/component/ (Wave 1a of +// ADR-085) and reaches the component registry/indexer machinery that still +// lives at the apps/cogos root through function variables declared in that +// package. This file sets those variables at init() time. +// +// The blank import on the component package ensures its own init() — which +// registers the provider with pkg/reconcile — runs. A blank import is not +// strictly required here because the explicit references below already pull +// the package in, but is documented in ADR-085 rule 2 and kept consistent +// for future provider extractions. + +package main + +import ( + "github.com/cogos-dev/cogos/internal/providers/component" +) + +func init() { + component.LoadRegistry = func(root string) (*component.Registry, error) { + reg, err := loadComponentRegistry(root) + if err != nil { + return nil, err + } + out := &component.Registry{ + Reconciler: component.ReconcilerConfig{ + PruneUnregistered: reg.Reconciler.PruneUnregistered, + }, + Components: make(map[string]component.RegistryDecl, len(reg.Components)), + } + for path, decl := range reg.Components { + out.Components[path] = component.RegistryDecl{ + Kind: decl.Kind, + Required: decl.Required, + } + } + return out, nil + } + + component.IndexComponentPaths = func(root string) ([]string, error) { + idx, err := indexComponents(root) + if err != nil { + return nil, err + } + paths := make([]string, 0, len(idx.Components)) + for path := range idx.Components { + paths = append(paths, path) + } + return paths, nil + } + + component.LoadBlob = func(root, path string) (*component.Blob, error) { + b, err := loadComponentBlob(root, path) + if err != nil { + return nil, err + } + return &component.Blob{ + Kind: b.Kind, + TreeHash: b.TreeHash, + CommitHash: b.CommitHash, + BlobHash: b.BlobHash, + Dirty: b.Dirty, + Language: b.Language, + BuildSystem: b.BuildSystem, + IndexedAt: b.IndexedAt, + }, nil + } + + component.EncodePath = encodePath + component.NowISO = nowISO +} diff --git a/decompose.go b/decompose.go new file mode 100644 index 0000000..e662700 --- /dev/null +++ b/decompose.go @@ -0,0 +1,846 @@ +// decompose.go — Decomposition Pipeline for CogOS +// +// Decomposes input text into a tiered representation: +// Tier 0: one-sentence summary (~15 tokens) +// Tier 1: paragraph summary + key terms (~100 tokens) +// Tier 2: full CogDoc structure (title, type, tags, sections, refs) +// Tier 3: raw passthrough (no LLM call) +// +// Uses AgentHarness.GenerateJSON() against a local model (Gemma E4B via Ollama). + +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "math" + "os" + "path/filepath" + "strings" + "time" +) + +// === Wave 0: Foundation — Tier JSON Schemas === + +// Tier0Result is a one-sentence summary (~15 tokens). +type Tier0Result struct { + Summary string `json:"summary"` +} + +// Tier1Result is a paragraph summary with key terms (~100 tokens). +type Tier1Result struct { + Summary string `json:"summary"` + KeyTerms []string `json:"key_terms"` +} + +// Tier2Result is a full CogDoc structure. +type Tier2Result struct { + Title string `json:"title"` + Type string `json:"type"` // knowledge, architecture, etc + Tags []string `json:"tags"` + Summary string `json:"summary"` + Sections []Tier2Section `json:"sections"` + Refs []Tier2Ref `json:"refs,omitempty"` +} + +// Tier2Section is a single section in the Tier 2 document. +type Tier2Section struct { + Heading string `json:"heading"` + Content string `json:"content"` +} + +// Tier2Ref is a reference link in the Tier 2 document. +type Tier2Ref struct { + URI string `json:"uri"` + Relation string `json:"relation"` +} + +// Tier 3 is raw passthrough — no schema needed, just store the input. + +// === Wave 0: Foundation — DecompositionResult === + +// DecompositionResult holds the complete output of a decomposition run. +type DecompositionResult struct { + InputHash string `json:"input_hash"` + InputSize int `json:"input_size"` + InputFormat string `json:"input_format"` + SourceURI string `json:"source_uri,omitempty"` + Tier0 *Tier0Result `json:"tier_0,omitempty"` + Tier1 *Tier1Result `json:"tier_1,omitempty"` + Tier2 *Tier2Result `json:"tier_2,omitempty"` + Tier3Raw string `json:"tier_3_raw,omitempty"` + Embeddings *DecompEmbeddings `json:"embeddings,omitempty"` + Quality *DecompQuality `json:"quality,omitempty"` + Metrics DecompMetrics `json:"metrics"` + CreatedAt time.Time `json:"created_at"` +} + +// DecompQuality holds quality metrics computed after decomposition completes. +type DecompQuality struct { + CompressionRatio float64 `json:"compression_ratio"` // input chars / tier0 chars + Tier0Fidelity float64 `json:"tier0_fidelity"` // cosine sim(T0 embed, T2 embed) + Tier1Fidelity float64 `json:"tier1_fidelity"` // cosine sim(T1 embed, T2 embed) + SchemaConformant bool `json:"schema_conformant"` // all tiers parsed cleanly +} + +// cosineSimilarity computes the cosine similarity between two float32 vectors. +// Returns 0.0 if either vector is nil/empty or if norms are zero. +func cosineSimilarity(a, b []float32) float64 { + if len(a) == 0 || len(b) == 0 { + return 0.0 + } + n := len(a) + if len(b) < n { + n = len(b) + } + var dot, normA, normB float64 + for i := 0; i < n; i++ { + dot += float64(a[i]) * float64(b[i]) + normA += float64(a[i]) * float64(a[i]) + normB += float64(b[i]) * float64(b[i]) + } + if normA == 0 || normB == 0 { + return 0.0 + } + return dot / (math.Sqrt(normA) * math.Sqrt(normB)) +} + +// computeQuality computes quality metrics from a completed decomposition result. +func computeQuality(result *DecompositionResult) *DecompQuality { + q := &DecompQuality{ + CompressionRatio: result.Metrics.CompressionRatio, + SchemaConformant: true, // overridden to false if any tier needed a retry + } + if result.Embeddings != nil { + if len(result.Embeddings.Tier0_128) > 0 && len(result.Embeddings.Tier2_128) > 0 { + q.Tier0Fidelity = cosineSimilarity(result.Embeddings.Tier0_128, result.Embeddings.Tier2_128) + } + if len(result.Embeddings.Tier1_128) > 0 && len(result.Embeddings.Tier2_128) > 0 { + q.Tier1Fidelity = cosineSimilarity(result.Embeddings.Tier1_128, result.Embeddings.Tier2_128) + } + } + return q +} + +// DecompEmbeddings holds embedding vectors at different tier/dimensionalities. +type DecompEmbeddings struct { + Tier0_128 []float32 `json:"tier0_128,omitempty"` + Tier1_128 []float32 `json:"tier1_128,omitempty"` + Tier2_128 []float32 `json:"tier2_128,omitempty"` + Tier2_768 []float32 `json:"tier2_768,omitempty"` +} + +// DecompMetrics records timing and compression data for each tier. +type DecompMetrics struct { + Tier0Tokens int `json:"tier0_tokens"` + Tier0LatencyMs int64 `json:"tier0_latency_ms"` + Tier1Tokens int `json:"tier1_tokens"` + Tier1LatencyMs int64 `json:"tier1_latency_ms"` + Tier2Tokens int `json:"tier2_tokens"` + Tier2LatencyMs int64 `json:"tier2_latency_ms"` + TotalLatencyMs int64 `json:"total_latency_ms"` + CompressionRatio float64 `json:"compression_ratio"` +} + +// === Wave 0: Foundation — Input Normalization === + +// DecompInput is the normalized input for decomposition. +type DecompInput struct { + Text string + Format string // "markdown", "plaintext", "conversation" + SourceURI string // set if from file + ByteSize int +} + +// normalizeDecompInput reads input from args (file path) or stdin. +func normalizeDecompInput(args []string, stdin io.Reader) (*DecompInput, error) { + var text string + var sourceURI string + + if len(args) > 0 && args[0] != "-" { + // Read from file + path := args[0] + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read input file: %w", err) + } + text = string(data) + sourceURI = "file://" + path + } else { + // Read from stdin (explicit "-" or piped stdin) + fi, err := os.Stdin.Stat() + if err != nil { + return nil, fmt.Errorf("stat stdin: %w", err) + } + if len(args) > 0 && args[0] == "-" || (fi.Mode()&os.ModeCharDevice) == 0 { + data, err := io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("read stdin: %w", err) + } + text = string(data) + } else { + return nil, fmt.Errorf("no input provided: pass a file path, pipe stdin, or use '-' for stdin") + } + } + + if len(strings.TrimSpace(text)) == 0 { + return nil, fmt.Errorf("empty input") + } + + format := detectFormat(text) + + if len(text) > 8192 { + fmt.Fprintf(os.Stderr, "Warning: input is %d chars (>8K), chunking is future work\n", len(text)) + } + + return &DecompInput{ + Text: text, + Format: format, + SourceURI: sourceURI, + ByteSize: len(text), + }, nil +} + +// detectFormat guesses whether input is markdown, conversation, or plaintext. +func detectFormat(text string) string { + // Check for markdown indicators + if strings.Contains(text, "\n# ") || strings.Contains(text, "\n## ") || + strings.HasPrefix(text, "# ") || strings.Contains(text, "```") || + strings.Contains(text, "\n- ") || strings.Contains(text, "\n* ") { + return "markdown" + } + // Check for conversation markers + if strings.Contains(text, "\nHuman:") || strings.Contains(text, "\nAssistant:") || + strings.Contains(text, "\nUser:") || strings.Contains(text, "\nAI:") { + return "conversation" + } + return "plaintext" +} + +// === Wave 0: Foundation — Bus Event Types === + +const ( + DecompEventStart = "decompose.start" + DecompEventTierStart = "decompose.tier.start" + DecompEventTierComplete = "decompose.tier.complete" + DecompEventComplete = "decompose.complete" + DecompEventError = "decompose.error" +) + +// DecompEventCallback is the bus event emission interface. +type DecompEventCallback func(eventType string, payload map[string]interface{}) + +// === Wave 1: Assembly — Prompt Templates === + +func tier0SystemPrompt() string { + return `Summarize the following text in exactly one sentence of 15 words maximum. Capture the core meaning as concisely as possible. + +Example of good output: {"summary": "CogOS decomposes text into tiered summaries for efficient memory indexing."} + +Do not include markdown formatting in JSON string values. +Respond as JSON: {"summary": "..."}` +} + +func tier1SystemPrompt() string { + return `Write a paragraph summary of the following text in 3-5 sentences, then list 3-5 key terms that capture the main concepts. + +Do not include markdown formatting in JSON string values. +Respond as JSON: {"summary": "your 3-5 sentence paragraph here", "key_terms": ["term1", "term2", "term3"]}` +} + +func tier2SystemPrompt() string { + return `Produce a structured document from the following text. Respond as JSON with this exact structure: + +{"title": "short descriptive title", "type": "knowledge", "tags": ["tag1", "tag2"], "summary": "paragraph summary", "sections": [{"heading": "Section Title", "content": "section body text"}], "refs": []} + +Field requirements: +- title: short descriptive title (5-10 words) +- type: one of "knowledge", "architecture", "procedure", "insight", "reference" +- tags: 2-5 lowercase tags +- summary: one paragraph summarizing the content +- sections: array of objects with "heading" (string) and "content" (string) capturing the logical structure +- refs: array of objects with "uri" and "relation" fields; only include if the text explicitly references other documents; use empty array [] otherwise + +Do not include markdown formatting in JSON string values. All values must be plain text strings.` +} + +func tierUserPrompt(input string) string { + return "Decompose the following:\n\n" + input +} + +// === Wave 1: Assembly — CLI Flag Parsing === + +func cmdDecompose(args []string) int { + fs := flag.NewFlagSet("decompose", flag.ContinueOnError) + tierFlag := fs.String("tier", "all", "Tiers to generate: all, 0, 1, 2, 3, or comma-separated (e.g. 0,1)") + jsonFlag := fs.Bool("json", false, "Output raw JSON") + modelFlag := fs.String("model", "", "Model override (default: gemma4:e4b)") + ollamaFlag := fs.String("ollama-url", "", "Ollama URL override") + noStoreFlag := fs.Bool("no-store", false, "Skip embedding generation and CogDoc storage") + workbenchFlag := fs.Bool("workbench", false, "Launch interactive TUI workbench") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Usage: cog decompose [flags] [file|-]\n") + return 1 + } + + // Parse tier selection + tiers, err := parseTierFlag(*tierFlag) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Normalize input + input, err := normalizeDecompInput(fs.Args(), os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Resolve model and URL + model := "gemma4:e4b" + if *modelFlag != "" { + model = *modelFlag + } else if env := os.Getenv("COG_AGENT_MODEL"); env != "" { + model = env + } + + ollamaURL := "http://localhost:11434" + if *ollamaFlag != "" { + ollamaURL = *ollamaFlag + } else if env := os.Getenv("OLLAMA_HOST"); env != "" { + ollamaURL = env + } + + // Create runner with file-based event callback when in a workspace + harness := NewAgentHarness(AgentHarnessConfig{ + OllamaURL: ollamaURL, + Model: model, + }) + + var callback DecompEventCallback + if wd, err := os.Getwd(); err == nil { + if root := findWorkspaceRoot(wd); root != "" { + callback = newFileEventCallback(root) + } + } + + runner := NewDecompositionRunner(harness, callback) + runner.tiers = tiers + + // Interactive workbench mode + if *workbenchFlag { + if err := runDecompWorkbench(input, runner); err != nil { + fmt.Fprintf(os.Stderr, "Workbench error: %v\n", err) + return 1 + } + return 0 + } + + // Run decomposition + ctx := context.Background() + result, err := runner.Run(ctx, input) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return 1 + } + + // Post-processing: embeddings, quality, storage (best-effort, skip with --no-store) + if !*noStoreFlag { + embedResults(ctx, ollamaURL, result) + + // Compute quality metrics after embeddings are available + quality := computeQuality(result) + quality.SchemaConformant = !runner.retried + result.Quality = quality + + // Find workspace root (look for .cog/ directory) + if wd, err := os.Getwd(); err == nil { + root := findWorkspaceRoot(wd) + if root != "" { + storeResult(root, result) + + // C4: Index the CogDoc in constellation for vector+FTS retrieval. + // Best-effort — log warning on failure, don't block output. + cogdocPath := filepath.Join(root, ".cog", "mem", "semantic", "decompositions", result.InputHash+".cog.md") + if err := indexInConstellation(root, cogdocPath); err != nil { + fmt.Fprintf(os.Stderr, "Warning: constellation index failed: %v\n", err) + } + } + } + } + + // Output + if *jsonFlag { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshalling result: %v\n", err) + return 1 + } + fmt.Println(string(data)) + } else { + printDecompResult(result) + } + + return 0 +} + +// parseTierFlag parses the --tier flag value into a slice of tier numbers. +func parseTierFlag(flag string) ([]int, error) { + if flag == "all" { + return []int{0, 1, 2, 3}, nil + } + parts := strings.Split(flag, ",") + var tiers []int + for _, p := range parts { + p = strings.TrimSpace(p) + switch p { + case "0": + tiers = append(tiers, 0) + case "1": + tiers = append(tiers, 1) + case "2": + tiers = append(tiers, 2) + case "3": + tiers = append(tiers, 3) + default: + return nil, fmt.Errorf("invalid tier %q: must be 0, 1, 2, 3, all, or comma-separated", p) + } + } + if len(tiers) == 0 { + return nil, fmt.Errorf("no tiers specified") + } + return tiers, nil +} + +// === Wave 3 B3: CLI Output Formatter === + +// ANSI color codes for decompose terminal output. +const ( + decompReset = "\033[0m" + decompBold = "\033[1m" + decompDim = "\033[2m" + decompMagenta = "\033[0;35m" + decompBoldCyan = "\033[1;36m" + decompBoldGreen = "\033[1;32m" + decompBoldYellow = "\033[1;33m" + decompBoldBlue = "\033[1;34m" +) + +// decompTierColor returns the ANSI color for a given tier level. +func decompTierColor(tier int) string { + switch tier { + case 0: + return decompBoldCyan + case 1: + return decompBoldGreen + case 2: + return decompBoldYellow + case 3: + return decompBoldBlue + default: + return decompBold + } +} + +// formatLatency formats milliseconds into a human-readable string. +func formatLatency(ms int64) string { + if ms < 1000 { + return fmt.Sprintf("%dms", ms) + } + return fmt.Sprintf("%.1fs", float64(ms)/1000.0) +} + +// decompFormatBytes formats a byte count with comma separators. +func decompFormatBytes(n int) string { + if n < 1000 { + return fmt.Sprintf("%d", n) + } + if n < 1000000 { + return fmt.Sprintf("%d,%03d", n/1000, n%1000) + } + return fmt.Sprintf("%d,%03d,%03d", n/1000000, (n/1000)%1000, n%1000) +} + +// printDecompResult prints a polished, ANSI-colored summary of the decomposition. +func printDecompResult(r *DecompositionResult) { + printDecompResultTo(os.Stdout, r) +} + +// printDecompResultTo writes the formatted decomposition result to the given writer. +func printDecompResultTo(w io.Writer, r *DecompositionResult) { + // Header + fmt.Fprintf(w, "\n%s═══ Decomposition Results ═══%s\n", decompBoldCyan, decompReset) + fmt.Fprintf(w, "Input: %s bytes (%s) %s[%s]%s\n", + decompFormatBytes(r.InputSize), r.InputFormat, decompDim, r.InputHash, decompReset) + if r.SourceURI != "" { + fmt.Fprintf(w, "Source: %s\n", r.SourceURI) + } + + // Tier 0 + if r.Tier0 != nil { + color := decompTierColor(0) + fmt.Fprintf(w, "\n%s── T0: Sentence (%d tok, %s) ──%s\n", + color, r.Metrics.Tier0Tokens, formatLatency(r.Metrics.Tier0LatencyMs), decompReset) + fmt.Fprintf(w, "%s\n", r.Tier0.Summary) + } + + // Tier 1 + if r.Tier1 != nil { + color := decompTierColor(1) + fmt.Fprintf(w, "\n%s── T1: Paragraph (%d tok, %s) ──%s\n", + color, r.Metrics.Tier1Tokens, formatLatency(r.Metrics.Tier1LatencyMs), decompReset) + fmt.Fprintf(w, "%s\n", r.Tier1.Summary) + if len(r.Tier1.KeyTerms) > 0 { + fmt.Fprintf(w, "%sKey terms:%s %s\n", decompDim, decompReset, strings.Join(r.Tier1.KeyTerms, ", ")) + } + } + + // Tier 2 + if r.Tier2 != nil { + color := decompTierColor(2) + fmt.Fprintf(w, "\n%s── T2: CogDoc (%d tok, %s) ──%s\n", + color, r.Metrics.Tier2Tokens, formatLatency(r.Metrics.Tier2LatencyMs), decompReset) + fmt.Fprintf(w, "%sTitle:%s %s\n", decompDim, decompReset, r.Tier2.Title) + fmt.Fprintf(w, "%sType:%s %s\n", decompDim, decompReset, r.Tier2.Type) + fmt.Fprintf(w, "%sTags:%s %s\n", decompDim, decompReset, strings.Join(r.Tier2.Tags, ", ")) + + // Section headings inline + if len(r.Tier2.Sections) > 0 { + var headings []string + for _, s := range r.Tier2.Sections { + headings = append(headings, "["+s.Heading+"]") + } + fmt.Fprintf(w, "%sSections:%s %s\n", decompDim, decompReset, strings.Join(headings, " ")) + // Section content + for _, s := range r.Tier2.Sections { + fmt.Fprintf(w, "\n %s%s%s\n", decompBold, s.Heading, decompReset) + fmt.Fprintf(w, " %s\n", s.Content) + } + } + + // Refs + if len(r.Tier2.Refs) > 0 { + fmt.Fprintf(w, "\n %sRefs:%s\n", decompDim, decompReset) + for _, ref := range r.Tier2.Refs { + fmt.Fprintf(w, " %s (%s)\n", ref.URI, ref.Relation) + } + } + } + + // Tier 3 + if r.Tier3Raw != "" { + color := decompTierColor(3) + fmt.Fprintf(w, "\n%s── T3: Raw (%s bytes) ──%s\n", + color, decompFormatBytes(len(r.Tier3Raw)), decompReset) + fmt.Fprintf(w, "%s[stored, not displayed — use --tier 3 to view]%s\n", decompDim, decompReset) + } + + // Metrics footer + fmt.Fprintf(w, "\n%s── Metrics ──%s\n", decompMagenta, decompReset) + parts := []string{ + fmt.Sprintf("Total: %s", formatLatency(r.Metrics.TotalLatencyMs)), + } + if r.Metrics.CompressionRatio > 0 { + parts = append(parts, fmt.Sprintf("Compression: %.0f:1 (T0)", r.Metrics.CompressionRatio)) + } + parts = append(parts, fmt.Sprintf("Input hash: %s", r.InputHash)) + fmt.Fprintf(w, "%s\n\n", strings.Join(parts, " | ")) +} + +// === Wave 3 D2: File-based Bus Event Emission === + +// decompBusEvent is the JSONL structure written to the bus event file. +type decompBusEvent struct { + Ts string `json:"ts"` + Type string `json:"type"` + From string `json:"from"` + Payload map[string]interface{} `json:"payload"` +} + +// newFileEventCallback creates a DecompEventCallback that writes JSONL events +// to .cog/.state/buses/decompose/events.jsonl under the given workspace root. +// If the bus directory cannot be created, the callback silently no-ops. +func newFileEventCallback(root string) DecompEventCallback { + busDir := filepath.Join(root, ".cog", ".state", "buses", "decompose") + if err := os.MkdirAll(busDir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "Warning: cannot create decompose bus dir: %v\n", err) + return func(string, map[string]interface{}) {} // no-op + } + + eventsPath := filepath.Join(busDir, "events.jsonl") + + return func(eventType string, payload map[string]interface{}) { + evt := decompBusEvent{ + Ts: time.Now().UTC().Format(time.RFC3339Nano), + Type: eventType, + From: "decompose", + Payload: payload, + } + line, err := json.Marshal(evt) + if err != nil { + return + } + f, err := os.OpenFile(eventsPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return + } + _, _ = f.WriteString(string(line) + "\n") + f.Close() + } +} + +// === Wave 2: Engine — GenerateJSON method on AgentHarness === + +// GenerateJSON sends a prompt to the model with JSON mode and returns raw content. +func (h *AgentHarness) GenerateJSON(ctx context.Context, systemPrompt, userPrompt string) (string, error) { + req := agentChatRequest{ + Model: h.model, + Messages: []agentChatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userPrompt}, + }, + Stream: false, + Think: false, + Format: "json", + } + resp, err := h.chatCompletion(ctx, req) + if err != nil { + return "", err + } + return resp.Message.Content, nil +} + +// === Wave 2: Engine — DecompositionRunner === + +// DecompositionRunner orchestrates the multi-tier decomposition pipeline. +type DecompositionRunner struct { + harness *AgentHarness + callback DecompEventCallback + tiers []int // which tiers to run (default: [0,1,2,3]) + retried bool // set to true if any tier needed a JSON parse retry +} + +// NewDecompositionRunner creates a runner with the given harness and optional event callback. +func NewDecompositionRunner(harness *AgentHarness, callback DecompEventCallback) *DecompositionRunner { + return &DecompositionRunner{ + harness: harness, + callback: callback, + tiers: []int{0, 1, 2, 3}, + } +} + +// emit sends a bus event if a callback is registered. +func (r *DecompositionRunner) emit(eventType string, payload map[string]interface{}) { + if r.callback != nil { + r.callback(eventType, payload) + } +} + +// hasTier checks whether a tier is in the requested set. +func (r *DecompositionRunner) hasTier(tier int) bool { + for _, t := range r.tiers { + if t == tier { + return true + } + } + return false +} + +// Run executes the decomposition pipeline on the given input. +func (r *DecompositionRunner) Run(ctx context.Context, input *DecompInput) (*DecompositionResult, error) { + totalStart := time.Now() + + // 1. Compute input hash (sha256, first 12 hex chars) + h := sha256.Sum256([]byte(input.Text)) + inputHash := hex.EncodeToString(h[:])[:12] + + result := &DecompositionResult{ + InputHash: inputHash, + InputSize: input.ByteSize, + InputFormat: input.Format, + SourceURI: input.SourceURI, + CreatedAt: time.Now(), + } + + // 2. Emit start event + r.emit(DecompEventStart, map[string]interface{}{ + "input_hash": inputHash, + "input_size": input.ByteSize, + "tiers": r.tiers, + }) + + userPrompt := tierUserPrompt(input.Text) + + // 3. Run each requested tier + if r.hasTier(0) { + t0, metrics, err := r.runTier0(ctx, userPrompt) + if err != nil { + r.emit(DecompEventError, map[string]interface{}{"tier": 0, "error": err.Error()}) + return nil, fmt.Errorf("tier 0: %w", err) + } + result.Tier0 = t0 + result.Metrics.Tier0Tokens = metrics.tokens + result.Metrics.Tier0LatencyMs = metrics.latencyMs + } + + if r.hasTier(1) { + t1, metrics, err := r.runTier1(ctx, userPrompt) + if err != nil { + r.emit(DecompEventError, map[string]interface{}{"tier": 1, "error": err.Error()}) + return nil, fmt.Errorf("tier 1: %w", err) + } + result.Tier1 = t1 + result.Metrics.Tier1Tokens = metrics.tokens + result.Metrics.Tier1LatencyMs = metrics.latencyMs + } + + if r.hasTier(2) { + t2, metrics, err := r.runTier2(ctx, userPrompt) + if err != nil { + r.emit(DecompEventError, map[string]interface{}{"tier": 2, "error": err.Error()}) + return nil, fmt.Errorf("tier 2: %w", err) + } + result.Tier2 = t2 + result.Metrics.Tier2Tokens = metrics.tokens + result.Metrics.Tier2LatencyMs = metrics.latencyMs + } + + // 4. Tier 3 = raw passthrough + if r.hasTier(3) { + result.Tier3Raw = input.Text + } + + // 5. Compute compression ratio (input chars / tier0 chars) + if result.Tier0 != nil && len(result.Tier0.Summary) > 0 { + result.Metrics.CompressionRatio = float64(len(input.Text)) / float64(len(result.Tier0.Summary)) + } + + // 6. Total latency + result.Metrics.TotalLatencyMs = time.Since(totalStart).Milliseconds() + + // 7. Emit complete + // Build complete event payload with full metrics + completePayload := map[string]interface{}{ + "input_hash": inputHash, + "input_size": input.ByteSize, + "total_latency": result.Metrics.TotalLatencyMs, + "tier0_tokens": result.Metrics.Tier0Tokens, + "tier0_latency_ms": result.Metrics.Tier0LatencyMs, + "tier1_tokens": result.Metrics.Tier1Tokens, + "tier1_latency_ms": result.Metrics.Tier1LatencyMs, + "tier2_tokens": result.Metrics.Tier2Tokens, + "tier2_latency_ms": result.Metrics.Tier2LatencyMs, + "compression_ratio": result.Metrics.CompressionRatio, + "schema_conformant": !r.retried, + } + r.emit(DecompEventComplete, completePayload) + + return result, nil +} + +// tierMetrics captures per-tier timing and token estimation. +type tierMetrics struct { + tokens int + latencyMs int64 +} + +// runTier0 generates a one-sentence summary. +func (r *DecompositionRunner) runTier0(ctx context.Context, userPrompt string) (*Tier0Result, tierMetrics, error) { + r.emit(DecompEventTierStart, map[string]interface{}{"tier": 0}) + start := time.Now() + + raw, err := r.harness.GenerateJSON(ctx, tier0SystemPrompt(), userPrompt) + if err != nil { + return nil, tierMetrics{}, err + } + + var t0 Tier0Result + if err := json.Unmarshal([]byte(raw), &t0); err != nil { + // Retry once with error-correction prompt + r.retried = true + correctionPrompt := tier0SystemPrompt() + "\n\nYour previous response was invalid JSON: " + err.Error() + "\nPlease respond with valid JSON only." + raw, err = r.harness.GenerateJSON(ctx, correctionPrompt, userPrompt) + if err != nil { + return nil, tierMetrics{}, fmt.Errorf("retry failed: %w", err) + } + if err := json.Unmarshal([]byte(raw), &t0); err != nil { + return nil, tierMetrics{}, fmt.Errorf("parse after retry: %w", err) + } + } + + m := tierMetrics{ + tokens: len(raw) / 4, + latencyMs: time.Since(start).Milliseconds(), + } + r.emit(DecompEventTierComplete, map[string]interface{}{"tier": 0, "latency_ms": m.latencyMs}) + return &t0, m, nil +} + +// runTier1 generates a paragraph summary with key terms. +func (r *DecompositionRunner) runTier1(ctx context.Context, userPrompt string) (*Tier1Result, tierMetrics, error) { + r.emit(DecompEventTierStart, map[string]interface{}{"tier": 1}) + start := time.Now() + + raw, err := r.harness.GenerateJSON(ctx, tier1SystemPrompt(), userPrompt) + if err != nil { + return nil, tierMetrics{}, err + } + + var t1 Tier1Result + if err := json.Unmarshal([]byte(raw), &t1); err != nil { + r.retried = true + correctionPrompt := tier1SystemPrompt() + "\n\nYour previous response was invalid JSON: " + err.Error() + "\nPlease respond with valid JSON only." + raw, err = r.harness.GenerateJSON(ctx, correctionPrompt, userPrompt) + if err != nil { + return nil, tierMetrics{}, fmt.Errorf("retry failed: %w", err) + } + if err := json.Unmarshal([]byte(raw), &t1); err != nil { + return nil, tierMetrics{}, fmt.Errorf("parse after retry: %w", err) + } + } + + m := tierMetrics{ + tokens: len(raw) / 4, + latencyMs: time.Since(start).Milliseconds(), + } + r.emit(DecompEventTierComplete, map[string]interface{}{"tier": 1, "latency_ms": m.latencyMs}) + return &t1, m, nil +} + +// runTier2 generates a full CogDoc structure. +func (r *DecompositionRunner) runTier2(ctx context.Context, userPrompt string) (*Tier2Result, tierMetrics, error) { + r.emit(DecompEventTierStart, map[string]interface{}{"tier": 2}) + start := time.Now() + + raw, err := r.harness.GenerateJSON(ctx, tier2SystemPrompt(), userPrompt) + if err != nil { + return nil, tierMetrics{}, err + } + + var t2 Tier2Result + if err := json.Unmarshal([]byte(raw), &t2); err != nil { + r.retried = true + correctionPrompt := tier2SystemPrompt() + "\n\nYour previous response was invalid JSON: " + err.Error() + "\nPlease respond with valid JSON only." + raw, err = r.harness.GenerateJSON(ctx, correctionPrompt, userPrompt) + if err != nil { + return nil, tierMetrics{}, fmt.Errorf("retry failed: %w", err) + } + if err := json.Unmarshal([]byte(raw), &t2); err != nil { + return nil, tierMetrics{}, fmt.Errorf("parse after retry: %w", err) + } + } + + m := tierMetrics{ + tokens: len(raw) / 4, + latencyMs: time.Since(start).Milliseconds(), + } + r.emit(DecompEventTierComplete, map[string]interface{}{"tier": 2, "latency_ms": m.latencyMs}) + return &t2, m, nil +} diff --git a/decompose_integration_test.go b/decompose_integration_test.go new file mode 100644 index 0000000..3a5dd54 --- /dev/null +++ b/decompose_integration_test.go @@ -0,0 +1,310 @@ +//go:build integration + +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestDecompE2EIntegration runs a full decomposition pipeline against a real +// Ollama instance with a sample markdown file, then verifies all tiers, embeddings, +// CogDoc storage, and bus event emission. +// +// Requires: Ollama running on localhost:11434 with gemma4:e4b loaded. +// Run with: go test -tags integration -run TestDecompE2EIntegration -timeout 120s +func TestDecompE2EIntegration(t *testing.T) { + // 1. Check Ollama availability — skip gracefully if not running + ollamaURL := "http://localhost:11434" + if env := os.Getenv("OLLAMA_HOST"); env != "" { + ollamaURL = env + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "GET", ollamaURL+"/api/tags", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Skipf("Ollama not available at %s: %v", ollamaURL, err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Skipf("Ollama returned status %d", resp.StatusCode) + } + + // 2. Create temp workspace with a sample .md file + tmpDir := t.TempDir() + cogDir := filepath.Join(tmpDir, ".cog") + if err := os.MkdirAll(cogDir, 0755); err != nil { + t.Fatalf("create .cog dir: %v", err) + } + + sampleContent := `--- +title: "Test ADR: Sample Architecture Decision" +type: architecture +status: accepted +--- + +# Test ADR: Sample Architecture Decision + +## Context + +The system needs a decomposition pipeline that breaks documents into tiered summaries. +Each tier provides a different level of detail, from a single sentence to the full document. + +## Decision + +We will implement a four-tier decomposition pipeline: +- **Tier 0**: One-sentence summary (~15 tokens) +- **Tier 1**: Paragraph summary with key terms (~100 tokens) +- **Tier 2**: Full CogDoc structure with sections +- **Tier 3**: Raw passthrough + +The pipeline uses a local LLM (Gemma E4B) via Ollama for tiers 0-2. + +## Consequences + +- Efficient memory indexing through tiered compression +- Local-first: no cloud API calls needed for decomposition +- Deterministic hashing enables deduplication +` + + samplePath := filepath.Join(tmpDir, "test-adr.md") + if err := os.WriteFile(samplePath, []byte(sampleContent), 0644); err != nil { + t.Fatalf("write sample file: %v", err) + } + + // 3. Create harness pointing at real Ollama + harness := NewAgentHarness(AgentHarnessConfig{ + OllamaURL: ollamaURL, + Model: "gemma4:e4b", + }) + + // Create file-based event callback for the temp workspace + callback := newFileEventCallback(tmpDir) + + runner := NewDecompositionRunner(harness, callback) + + // 4. Run decomposition with all tiers + runCtx, runCancel := context.WithTimeout(context.Background(), 90*time.Second) + defer runCancel() + + input := &DecompInput{ + Text: sampleContent, + Format: detectFormat(sampleContent), + SourceURI: "file://" + samplePath, + ByteSize: len(sampleContent), + } + + result, err := runner.Run(runCtx, input) + if err != nil { + t.Fatalf("decomposition run failed: %v", err) + } + + // 5. Verify all tiers are populated + t.Run("tier_0_populated", func(t *testing.T) { + if result.Tier0 == nil { + t.Fatal("Tier0 is nil") + } + if result.Tier0.Summary == "" { + t.Error("Tier0 summary is empty") + } + t.Logf("Tier 0 summary: %s", result.Tier0.Summary) + }) + + t.Run("tier_1_populated", func(t *testing.T) { + if result.Tier1 == nil { + t.Fatal("Tier1 is nil") + } + if result.Tier1.Summary == "" { + t.Error("Tier1 summary is empty") + } + if len(result.Tier1.KeyTerms) == 0 { + t.Error("Tier1 key_terms is empty") + } + t.Logf("Tier 1 key terms: %v", result.Tier1.KeyTerms) + }) + + t.Run("tier_2_populated", func(t *testing.T) { + if result.Tier2 == nil { + t.Fatal("Tier2 is nil") + } + if result.Tier2.Title == "" { + t.Error("Tier2 title is empty") + } + if result.Tier2.Type == "" { + t.Error("Tier2 type is empty") + } + if len(result.Tier2.Tags) == 0 { + t.Error("Tier2 tags is empty") + } + if len(result.Tier2.Sections) == 0 { + t.Error("Tier2 sections is empty") + } + t.Logf("Tier 2 title: %s, type: %s", result.Tier2.Title, result.Tier2.Type) + }) + + t.Run("tier_3_raw", func(t *testing.T) { + if result.Tier3Raw == "" { + t.Error("Tier3Raw is empty") + } + if result.Tier3Raw != sampleContent { + t.Error("Tier3Raw does not match original input") + } + }) + + // 6. Verify metrics + t.Run("metrics", func(t *testing.T) { + m := result.Metrics + if m.TotalLatencyMs <= 0 { + t.Error("total_latency_ms should be positive") + } + if m.Tier0LatencyMs <= 0 { + t.Error("tier0_latency_ms should be positive") + } + if m.CompressionRatio <= 0 { + t.Error("compression_ratio should be positive") + } + t.Logf("Latency: total=%dms, t0=%dms, t1=%dms, t2=%dms, compression=%.1f:1", + m.TotalLatencyMs, m.Tier0LatencyMs, m.Tier1LatencyMs, m.Tier2LatencyMs, m.CompressionRatio) + }) + + // 7. Verify embeddings were attempted (best-effort — check if non-nil) + t.Run("embeddings_attempted", func(t *testing.T) { + embedResults(runCtx, ollamaURL, result) + if result.Embeddings == nil { + t.Log("Embeddings are nil — embed model may not be available (acceptable)") + } else { + if len(result.Embeddings.Tier0_128) == 0 { + t.Log("Tier0_128 embeddings empty — truncation may have produced empty vector") + } else { + t.Logf("Tier0_128 has %d dimensions", len(result.Embeddings.Tier0_128)) + } + if len(result.Embeddings.Tier2_768) == 0 { + t.Log("Tier2_768 embeddings empty") + } else { + t.Logf("Tier2_768 has %d dimensions", len(result.Embeddings.Tier2_768)) + } + } + }) + + // 8. Verify CogDoc was written + t.Run("cogdoc_stored", func(t *testing.T) { + storeResult(tmpDir, result) + decompDir := filepath.Join(tmpDir, ".cog", "mem", "semantic", "decompositions") + cogdocPath := filepath.Join(decompDir, result.InputHash+".cog.md") + + if _, err := os.Stat(cogdocPath); os.IsNotExist(err) { + t.Fatalf("CogDoc not found at %s", cogdocPath) + } + + content, err := os.ReadFile(cogdocPath) + if err != nil { + t.Fatalf("read CogDoc: %v", err) + } + + cogdoc := string(content) + // Verify it has YAML frontmatter + if !strings.HasPrefix(cogdoc, "---\n") { + t.Error("CogDoc missing YAML frontmatter") + } + // Verify it contains tier sections + if !strings.Contains(cogdoc, "# Tier 0") { + t.Error("CogDoc missing Tier 0 section") + } + if !strings.Contains(cogdoc, "# Tier 1") { + t.Error("CogDoc missing Tier 1 section") + } + if !strings.Contains(cogdoc, "# Tier 2") { + t.Error("CogDoc missing Tier 2 section") + } + t.Logf("CogDoc written: %d bytes at %s", len(content), cogdocPath) + }) + + // 9. Verify bus events were emitted + t.Run("bus_events_emitted", func(t *testing.T) { + eventsPath := filepath.Join(tmpDir, ".cog", ".state", "buses", "decompose", "events.jsonl") + if _, err := os.Stat(eventsPath); os.IsNotExist(err) { + t.Fatalf("bus events file not found at %s", eventsPath) + } + + data, err := os.ReadFile(eventsPath) + if err != nil { + t.Fatalf("read events: %v", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 2 { + t.Fatalf("expected at least 2 events (start + complete), got %d", len(lines)) + } + + // Parse and verify event types + var eventTypes []string + for _, line := range lines { + var evt struct { + Type string `json:"type"` + From string `json:"from"` + } + if err := json.Unmarshal([]byte(line), &evt); err != nil { + t.Errorf("parse event line: %v", err) + continue + } + if evt.From != "decompose" { + t.Errorf("event from=%q, expected 'decompose'", evt.From) + } + eventTypes = append(eventTypes, evt.Type) + } + + // Must have start and complete + hasStart := false + hasComplete := false + for _, et := range eventTypes { + if et == DecompEventStart { + hasStart = true + } + if et == DecompEventComplete { + hasComplete = true + } + } + if !hasStart { + t.Error("missing decompose.start event") + } + if !hasComplete { + t.Error("missing decompose.complete event") + } + t.Logf("Bus events: %d total, types: %v", len(lines), eventTypes) + }) + + // 10. Verify JSON round-trip + t.Run("json_roundtrip", func(t *testing.T) { + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("marshal result: %v", err) + } + var roundTripped DecompositionResult + if err := json.Unmarshal(data, &roundTripped); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if roundTripped.InputHash != result.InputHash { + t.Error("input_hash mismatch after round-trip") + } + if roundTripped.Tier0 == nil || roundTripped.Tier0.Summary != result.Tier0.Summary { + t.Error("tier_0 mismatch after round-trip") + } + }) + + fmt.Printf("\n=== Integration Test Summary ===\n") + fmt.Printf("Input: %d bytes (%s)\n", result.InputSize, result.InputFormat) + fmt.Printf("Tier 0: %s\n", result.Tier0.Summary) + fmt.Printf("Tier 1 terms: %v\n", result.Tier1.KeyTerms) + fmt.Printf("Tier 2: %s [%s]\n", result.Tier2.Title, result.Tier2.Type) + fmt.Printf("Latency: %dms total\n", result.Metrics.TotalLatencyMs) + fmt.Printf("Compression: %.0f:1\n", result.Metrics.CompressionRatio) +} diff --git a/decompose_store.go b/decompose_store.go new file mode 100644 index 0000000..16a8799 --- /dev/null +++ b/decompose_store.go @@ -0,0 +1,306 @@ +// decompose_store.go — Wave 3: Embedding generation and CogDoc storage +// +// embedResults() calls Ollama's native /api/embed endpoint to generate +// vector embeddings for each decomposition tier. Best-effort: if the +// embed endpoint is unreachable, logs a warning and continues. +// +// buildCogDoc() renders a DecompositionResult as a CogDoc markdown file +// with YAML frontmatter suitable for storage in .cog/mem/semantic/. +// +// storeResult() writes the CogDoc to disk. Also best-effort. + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/cogos-dev/cogos/sdk/constellation" +) + +// === C1+C2: Embedding Generation === + +// ollamaEmbedRequest is the request body for Ollama's /api/embed endpoint. +type ollamaEmbedRequest struct { + Model string `json:"model"` + Input string `json:"input"` +} + +// ollamaEmbedResponse is the response from Ollama's /api/embed endpoint. +type ollamaEmbedResponse struct { + Embeddings [][]float32 `json:"embeddings"` +} + +// embedFromOllama calls Ollama's native embed endpoint for a single text. +// Returns the full embedding vector (768-dim for nomic-embed-text) or error. +func embedFromOllama(ctx context.Context, ollamaURL, text string) ([]float32, error) { + reqBody := ollamaEmbedRequest{ + Model: "nomic-embed-text", + Input: text, + } + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal embed request: %w", err) + } + + url := ollamaURL + "/api/embed" + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("create embed request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("embed request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("embed server returned %d: %s", resp.StatusCode, string(body)) + } + + var embedResp ollamaEmbedResponse + if err := json.NewDecoder(resp.Body).Decode(&embedResp); err != nil { + return nil, fmt.Errorf("decode embed response: %w", err) + } + + if len(embedResp.Embeddings) == 0 { + return nil, fmt.Errorf("embed server returned no embeddings") + } + + return embedResp.Embeddings[0], nil +} + +// truncateVec returns the first n elements of a vector (Matryoshka truncation). +func truncateVec(v []float32, n int) []float32 { + if len(v) <= n { + return v + } + out := make([]float32, n) + copy(out, v[:n]) + return out +} + +// embedResults generates embeddings for each tier in the decomposition result. +// Best-effort: logs warnings on failure, never returns an error. +func embedResults(ctx context.Context, ollamaURL string, result *DecompositionResult) { + if result == nil { + return + } + + emb := &DecompEmbeddings{} + var generated bool + + // Tier 0: embed the one-sentence summary + if result.Tier0 != nil && result.Tier0.Summary != "" { + vec, err := embedFromOllama(ctx, ollamaURL, "search_document: "+result.Tier0.Summary) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: tier 0 embedding failed: %v\n", err) + } else { + emb.Tier0_128 = truncateVec(vec, 128) + generated = true + } + } + + // Tier 1: embed the paragraph summary + if result.Tier1 != nil && result.Tier1.Summary != "" { + vec, err := embedFromOllama(ctx, ollamaURL, "search_document: "+result.Tier1.Summary) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: tier 1 embedding failed: %v\n", err) + } else { + emb.Tier1_128 = truncateVec(vec, 128) + generated = true + } + } + + // Tier 2: embed full content (title + summary + sections concatenated) + if result.Tier2 != nil { + var parts []string + if result.Tier2.Title != "" { + parts = append(parts, result.Tier2.Title) + } + if result.Tier2.Summary != "" { + parts = append(parts, result.Tier2.Summary) + } + for _, s := range result.Tier2.Sections { + parts = append(parts, s.Heading+": "+s.Content) + } + if len(parts) > 0 { + fullText := strings.Join(parts, "\n") + vec, err := embedFromOllama(ctx, ollamaURL, "search_document: "+fullText) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: tier 2 embedding failed: %v\n", err) + } else { + emb.Tier2_768 = vec + emb.Tier2_128 = truncateVec(vec, 128) + generated = true + } + } + } + + if generated { + result.Embeddings = emb + } +} + +// === C3: CogDoc Storage === + +// buildCogDoc renders a DecompositionResult as a CogDoc markdown string +// with YAML frontmatter. +func buildCogDoc(result *DecompositionResult) string { + var b strings.Builder + + // --- YAML frontmatter --- + title := "Decomposition of " + result.InputHash + if result.Tier2 != nil && result.Tier2.Title != "" { + title = result.Tier2.Title + } + + var tags []string + if result.Tier2 != nil { + tags = result.Tier2.Tags + } + + sourceURI := result.SourceURI + if sourceURI == "" { + sourceURI = "hash://" + result.InputHash + } + + b.WriteString("---\n") + b.WriteString(fmt.Sprintf("title: %q\n", title)) + b.WriteString("type: decomposition\n") + b.WriteString(fmt.Sprintf("created: %s\n", result.CreatedAt.Format(time.RFC3339))) + b.WriteString("status: active\n") + b.WriteString("salience: medium\n") + b.WriteString("memory_sector: semantic\n") + + // tags + if len(tags) > 0 { + quotedTags := make([]string, len(tags)) + for i, t := range tags { + quotedTags[i] = fmt.Sprintf("%q", t) + } + b.WriteString(fmt.Sprintf("tags: [%s]\n", strings.Join(quotedTags, ", "))) + } else { + b.WriteString("tags: []\n") + } + + // refs + b.WriteString("refs:\n") + b.WriteString(fmt.Sprintf(" - uri: %q\n", sourceURI)) + b.WriteString(" rel: decomposes\n") + + b.WriteString("sections: [tier-0, tier-1, tier-2, metadata]\n") + b.WriteString("---\n\n") + + // --- Body sections --- + + // Tier 0 + b.WriteString("# Tier 0 — Sentence\n") + if result.Tier0 != nil { + b.WriteString(result.Tier0.Summary) + } else { + b.WriteString("_(not generated)_") + } + b.WriteString("\n\n") + + // Tier 1 + b.WriteString("# Tier 1 — Paragraph\n") + if result.Tier1 != nil { + b.WriteString(result.Tier1.Summary) + if len(result.Tier1.KeyTerms) > 0 { + b.WriteString("\n\n**Key terms:** ") + b.WriteString(strings.Join(result.Tier1.KeyTerms, ", ")) + } + } else { + b.WriteString("_(not generated)_") + } + b.WriteString("\n\n") + + // Tier 2 + b.WriteString("# Tier 2 — Structured\n") + if result.Tier2 != nil { + for _, s := range result.Tier2.Sections { + b.WriteString(fmt.Sprintf("## %s\n%s\n\n", s.Heading, s.Content)) + } + } else { + b.WriteString("_(not generated)_\n\n") + } + + // Metadata + b.WriteString("# Metadata\n") + b.WriteString(fmt.Sprintf("- Input: %d bytes (%s)\n", result.InputSize, result.InputFormat)) + b.WriteString(fmt.Sprintf("- Hash: %s\n", result.InputHash)) + if result.Metrics.CompressionRatio > 0 { + b.WriteString(fmt.Sprintf("- Compression: %.1f:1\n", result.Metrics.CompressionRatio)) + } + b.WriteString(fmt.Sprintf("- Generated: %s\n", result.CreatedAt.Format(time.RFC3339))) + + return b.String() +} + +// storeResult writes the CogDoc to .cog/mem/semantic/decompositions/{hash}.cog.md. +// Best-effort: logs warnings on failure, never returns an error. +func storeResult(workspaceRoot string, result *DecompositionResult) { + if result == nil || workspaceRoot == "" { + return + } + + dir := filepath.Join(workspaceRoot, ".cog", "mem", "semantic", "decompositions") + if err := os.MkdirAll(dir, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not create decomposition dir: %v\n", err) + return + } + + path := filepath.Join(dir, result.InputHash+".cog.md") + content := buildCogDoc(result) + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not write CogDoc: %v\n", err) + return + } + + fmt.Fprintf(os.Stderr, "Stored: %s\n", path) +} + +// indexInConstellation indexes a CogDoc file in the constellation database +// for vector+FTS5 retrieval. Best-effort: returns error on failure but +// callers should treat it as non-fatal. +func indexInConstellation(workspaceRoot string, cogdocPath string) error { + c, err := constellation.Open(workspaceRoot) + if err != nil { + return fmt.Errorf("open constellation: %w", err) + } + defer c.Close() + + if err := c.IndexFile(cogdocPath); err != nil { + return fmt.Errorf("index file: %w", err) + } + return nil +} + +// findWorkspaceRoot walks up from dir looking for a .cog/ directory. +// Returns the workspace root path, or empty string if not found. +func findWorkspaceRoot(dir string) string { + for { + if fi, err := os.Stat(filepath.Join(dir, ".cog")); err == nil && fi.IsDir() { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} diff --git a/decompose_store_test.go b/decompose_store_test.go new file mode 100644 index 0000000..5819753 --- /dev/null +++ b/decompose_store_test.go @@ -0,0 +1,238 @@ +package main + +import ( + "os" + "strings" + "testing" + "time" +) + +func TestDecompBuildCogDoc(t *testing.T) { + result := &DecompositionResult{ + InputHash: "abcdef123456", + InputSize: 2048, + InputFormat: "markdown", + SourceURI: "file:///tmp/test.md", + Tier0: &Tier0Result{Summary: "A test document about decomposition."}, + Tier1: &Tier1Result{ + Summary: "This document covers the CogOS decomposition pipeline in detail.", + KeyTerms: []string{"decomposition", "pipeline", "CogOS"}, + }, + Tier2: &Tier2Result{ + Title: "Decomposition Pipeline Overview", + Type: "architecture", + Tags: []string{"decompose", "pipeline", "cogos"}, + Summary: "A structured overview of the decomposition pipeline.", + Sections: []Tier2Section{ + {Heading: "Overview", Content: "The pipeline processes text into tiers."}, + {Heading: "Implementation", Content: "Uses Ollama for LLM calls."}, + }, + Refs: []Tier2Ref{ + {URI: "cog://mem/semantic/architecture/pipeline", Relation: "extends"}, + }, + }, + Metrics: DecompMetrics{ + CompressionRatio: 15.3, + TotalLatencyMs: 250, + }, + CreatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC), + } + + doc := buildCogDoc(result) + + // Check YAML frontmatter + if !strings.HasPrefix(doc, "---\n") { + t.Error("CogDoc should start with YAML frontmatter delimiter") + } + if !strings.Contains(doc, "---\n\n") { + t.Error("CogDoc should have closing frontmatter delimiter followed by blank line") + } + + // Check frontmatter fields + if !strings.Contains(doc, `title: "Decomposition Pipeline Overview"`) { + t.Error("frontmatter should contain the tier 2 title") + } + if !strings.Contains(doc, "type: decomposition") { + t.Error("frontmatter should contain type: decomposition") + } + if !strings.Contains(doc, "created: 2026-04-15T12:00:00Z") { + t.Error("frontmatter should contain created timestamp") + } + if !strings.Contains(doc, "status: active") { + t.Error("frontmatter should contain status: active") + } + if !strings.Contains(doc, "salience: medium") { + t.Error("frontmatter should contain salience: medium") + } + if !strings.Contains(doc, "memory_sector: semantic") { + t.Error("frontmatter should contain memory_sector: semantic") + } + if !strings.Contains(doc, `"decompose"`) { + t.Error("frontmatter should contain tags") + } + if !strings.Contains(doc, `uri: "file:///tmp/test.md"`) { + t.Error("frontmatter should contain source URI ref") + } + if !strings.Contains(doc, "rel: decomposes") { + t.Error("frontmatter should contain rel: decomposes") + } + if !strings.Contains(doc, "sections: [tier-0, tier-1, tier-2, metadata]") { + t.Error("frontmatter should list all 4 sections") + } + + // Check all 4 body sections exist + if !strings.Contains(doc, "# Tier 0 — Sentence") { + t.Error("CogDoc should contain Tier 0 section") + } + if !strings.Contains(doc, "A test document about decomposition.") { + t.Error("CogDoc should contain tier 0 summary text") + } + + if !strings.Contains(doc, "# Tier 1 — Paragraph") { + t.Error("CogDoc should contain Tier 1 section") + } + if !strings.Contains(doc, "**Key terms:** decomposition, pipeline, CogOS") { + t.Error("CogDoc should contain key terms") + } + + if !strings.Contains(doc, "# Tier 2 — Structured") { + t.Error("CogDoc should contain Tier 2 section") + } + if !strings.Contains(doc, "## Overview") { + t.Error("CogDoc should contain tier 2 section headings") + } + if !strings.Contains(doc, "## Implementation") { + t.Error("CogDoc should contain tier 2 section headings") + } + + if !strings.Contains(doc, "# Metadata") { + t.Error("CogDoc should contain Metadata section") + } + if !strings.Contains(doc, "- Input: 2048 bytes (markdown)") { + t.Error("CogDoc should contain input metadata") + } + if !strings.Contains(doc, "- Hash: abcdef123456") { + t.Error("CogDoc should contain hash") + } + if !strings.Contains(doc, "- Compression: 15.3:1") { + t.Error("CogDoc should contain compression ratio") + } +} + +func TestDecompBuildCogDocMinimal(t *testing.T) { + // Minimal result with only Tier 0, no Tier 1/2 + result := &DecompositionResult{ + InputHash: "deadbeef0000", + InputSize: 100, + InputFormat: "plaintext", + Tier0: &Tier0Result{Summary: "A short note."}, + CreatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC), + } + + doc := buildCogDoc(result) + + // Title should fall back to hash-based + if !strings.Contains(doc, `title: "Decomposition of deadbeef0000"`) { + t.Error("minimal CogDoc should use hash-based title") + } + + // Should have placeholder text for missing tiers + if !strings.Contains(doc, "_(not generated)_") { + t.Error("missing tiers should show placeholder text") + } + + // Tags should be empty array + if !strings.Contains(doc, "tags: []") { + t.Error("minimal CogDoc should have empty tags array") + } + + // Source URI should use hash scheme + if !strings.Contains(doc, `uri: "hash://deadbeef0000"`) { + t.Error("minimal CogDoc should use hash-based source URI") + } +} + +func TestDecompIndexInConstellationMissingRoot(t *testing.T) { + // indexInConstellation should return an error (not panic) when the + // workspace root doesn't contain a valid .cog directory or when the + // cogdoc file doesn't exist. + err := indexInConstellation("/nonexistent/workspace/root", "/nonexistent/file.cog.md") + if err == nil { + t.Fatal("expected error for nonexistent workspace root, got nil") + } +} + +func TestDecompIndexInConstellationBadFile(t *testing.T) { + // Create a temp workspace with .cog/.state so constellation.Open succeeds, + // but point to a nonexistent cogdoc file. + tmpDir := t.TempDir() + stateDir := tmpDir + "/.cog/.state" + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + + err := indexInConstellation(tmpDir, tmpDir+"/nonexistent.cog.md") + if err == nil { + t.Fatal("expected error for nonexistent cogdoc file, got nil") + } +} + +func TestDecompIndexInConstellationSuccess(t *testing.T) { + // Create a temp workspace with a valid cogdoc and verify it indexes. + tmpDir := t.TempDir() + stateDir := tmpDir + "/.cog/.state" + memDir := tmpDir + "/.cog/mem/semantic/decompositions" + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(memDir, 0o755); err != nil { + t.Fatal(err) + } + + cogdoc := `--- +title: "Test Doc" +type: decomposition +created: 2026-04-15T12:00:00Z +status: active +tags: ["test"] +--- + +# Tier 0 — Sentence +A test document. +` + cogdocPath := memDir + "/abc123.cog.md" + if err := os.WriteFile(cogdocPath, []byte(cogdoc), 0o644); err != nil { + t.Fatal(err) + } + + err := indexInConstellation(tmpDir, cogdocPath) + if err != nil { + // FTS5 requires a build tag (-tags fts5). Skip if unavailable. + if strings.Contains(err.Error(), "no such module: fts5") { + t.Skip("FTS5 not available (build with -tags fts5)") + } + t.Fatalf("expected successful indexing, got error: %v", err) + } +} + +func TestDecompTruncateVec(t *testing.T) { + v := make([]float32, 768) + for i := range v { + v[i] = float32(i) + } + + t128 := truncateVec(v, 128) + if len(t128) != 128 { + t.Errorf("expected 128 dims, got %d", len(t128)) + } + if t128[0] != 0 || t128[127] != 127 { + t.Error("truncated vector should preserve first N elements") + } + + // Truncating to larger size should return original + short := []float32{1, 2, 3} + result := truncateVec(short, 128) + if len(result) != 3 { + t.Errorf("truncating short vec should return original, got len %d", len(result)) + } +} diff --git a/decompose_test.go b/decompose_test.go new file mode 100644 index 0000000..3efb5ea --- /dev/null +++ b/decompose_test.go @@ -0,0 +1,1520 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// === E1: Schema Tests === + +func TestDecompTier0Unmarshal(t *testing.T) { + raw := `{"summary": "This document describes the CogOS decomposition pipeline."}` + var t0 Tier0Result + if err := json.Unmarshal([]byte(raw), &t0); err != nil { + t.Fatalf("failed to unmarshal Tier0Result: %v", err) + } + if t0.Summary == "" { + t.Error("expected non-empty summary") + } + if !strings.Contains(t0.Summary, "CogOS") { + t.Errorf("expected summary to contain 'CogOS', got: %s", t0.Summary) + } +} + +func TestDecompTier1Unmarshal(t *testing.T) { + raw := `{ + "summary": "The CogOS decomposition pipeline breaks input into tiered summaries for memory indexing.", + "key_terms": ["decomposition", "tiered", "memory", "indexing"] + }` + var t1 Tier1Result + if err := json.Unmarshal([]byte(raw), &t1); err != nil { + t.Fatalf("failed to unmarshal Tier1Result: %v", err) + } + if t1.Summary == "" { + t.Error("expected non-empty summary") + } + if len(t1.KeyTerms) != 4 { + t.Errorf("expected 4 key terms, got %d", len(t1.KeyTerms)) + } +} + +func TestDecompTier2Unmarshal(t *testing.T) { + raw := `{ + "title": "Decomposition Pipeline", + "type": "architecture", + "tags": ["decompose", "pipeline", "cogos"], + "summary": "A multi-tier decomposition pipeline for CogOS memory ingestion.", + "sections": [ + {"heading": "Overview", "content": "The pipeline decomposes text into four tiers."}, + {"heading": "Tiers", "content": "Tier 0 is a sentence, Tier 1 a paragraph, Tier 2 a CogDoc, Tier 3 raw."} + ], + "refs": [ + {"uri": "cog://mem/semantic/architecture/pipeline", "relation": "extends"} + ] + }` + var t2 Tier2Result + if err := json.Unmarshal([]byte(raw), &t2); err != nil { + t.Fatalf("failed to unmarshal Tier2Result: %v", err) + } + if t2.Title != "Decomposition Pipeline" { + t.Errorf("expected title 'Decomposition Pipeline', got: %s", t2.Title) + } + if t2.Type != "architecture" { + t.Errorf("expected type 'architecture', got: %s", t2.Type) + } + if len(t2.Tags) != 3 { + t.Errorf("expected 3 tags, got %d", len(t2.Tags)) + } + if len(t2.Sections) != 2 { + t.Errorf("expected 2 sections, got %d", len(t2.Sections)) + } + if len(t2.Refs) != 1 { + t.Errorf("expected 1 ref, got %d", len(t2.Refs)) + } +} + +func TestDecompTier2NoRefs(t *testing.T) { + raw := `{ + "title": "Simple Note", + "type": "knowledge", + "tags": ["note"], + "summary": "A simple note.", + "sections": [{"heading": "Content", "content": "Hello world."}] + }` + var t2 Tier2Result + if err := json.Unmarshal([]byte(raw), &t2); err != nil { + t.Fatalf("failed to unmarshal Tier2Result without refs: %v", err) + } + if len(t2.Refs) != 0 { + t.Errorf("expected 0 refs, got %d", len(t2.Refs)) + } +} + +func TestDecompMalformedJSON(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"truncated", `{"summary": "hello`}, + {"not json", `this is plain text`}, + {"wrong type", `{"summary": 42}`}, + {"empty", ``}, + } + for _, tc := range cases { + t.Run("Tier0_"+tc.name, func(t *testing.T) { + var t0 Tier0Result + err := json.Unmarshal([]byte(tc.raw), &t0) + if err == nil && tc.name != "wrong type" { + // "wrong type" may not error since int->string coercion differs + // but at minimum the other cases should fail + t.Errorf("expected error for %q, got nil", tc.name) + } + }) + } +} + +// === CycleMemoryEntry Quality Round-trip Tests === + +func TestDecompCycleMemoryEntryQualityRoundTrip(t *testing.T) { + now := time.Now().Truncate(time.Millisecond) // truncate for JSON round-trip precision + + cases := []struct { + name string + quality string + }{ + {"decomposed", "decomposed"}, + {"fallback", "fallback"}, + {"empty", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + entry := cycleMemoryEntry{ + Cycle: 42, + Action: "observe", + Urgency: 0.7, + Sentence: "Test sentence for quality round-trip.", + Timestamp: now, + Quality: tc.quality, + } + + data, err := json.Marshal(entry) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded cycleMemoryEntry + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.Quality != tc.quality { + t.Errorf("quality mismatch: got %q, want %q", decoded.Quality, tc.quality) + } + if decoded.Cycle != entry.Cycle { + t.Errorf("cycle mismatch: got %d, want %d", decoded.Cycle, entry.Cycle) + } + if decoded.Action != entry.Action { + t.Errorf("action mismatch: got %q, want %q", decoded.Action, entry.Action) + } + if decoded.Urgency != entry.Urgency { + t.Errorf("urgency mismatch: got %f, want %f", decoded.Urgency, entry.Urgency) + } + if decoded.Sentence != entry.Sentence { + t.Errorf("sentence mismatch: got %q, want %q", decoded.Sentence, entry.Sentence) + } + + // Verify the JSON contains the quality field + if !strings.Contains(string(data), `"quality"`) { + t.Error("serialized JSON should contain 'quality' field") + } + }) + } +} + +func TestDecompCycleMemoryFormatObservationQualityTag(t *testing.T) { + mem := newAgentCycleMemory(10) + + mem.append(cycleMemoryEntry{ + Cycle: 1, + Action: "observe", + Urgency: 0.5, + Sentence: "Normal decomposed entry.", + Timestamp: time.Now(), + Quality: "decomposed", + }) + mem.append(cycleMemoryEntry{ + Cycle: 2, + Action: "sleep", + Urgency: 0.1, + Sentence: "Fallback entry due to GPU timeout.", + Timestamp: time.Now(), + Quality: "fallback", + }) + + output := mem.formatForObservation() + + // The decomposed entry should NOT have [fallback] tag + if strings.Contains(output, "Normal decomposed entry.") && strings.Contains(output, "[fallback]") { + // Make sure [fallback] is only on the fallback line + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "Normal decomposed entry.") && strings.Contains(line, "[fallback]") { + t.Error("decomposed entry should not have [fallback] tag") + } + } + } + + // The fallback entry should have [fallback] tag + lines := strings.Split(output, "\n") + foundFallbackTag := false + for _, line := range lines { + if strings.Contains(line, "Fallback entry due to GPU timeout.") { + if strings.Contains(line, "[fallback]") { + foundFallbackTag = true + } + } + } + if !foundFallbackTag { + t.Error("fallback entry should have [fallback] tag in observation output") + } +} + +// === Prompt Template Tests === + +func TestDecompPromptTemplates(t *testing.T) { + t.Run("tier0", func(t *testing.T) { + p := tier0SystemPrompt() + if !strings.Contains(p, "one sentence") { + t.Error("tier0 prompt should mention 'one sentence'") + } + if !strings.Contains(p, "JSON") { + t.Error("tier0 prompt should mention JSON format") + } + }) + + t.Run("tier1", func(t *testing.T) { + p := tier1SystemPrompt() + if !strings.Contains(p, "paragraph") { + t.Error("tier1 prompt should mention 'paragraph'") + } + if !strings.Contains(p, "key_terms") { + t.Error("tier1 prompt should mention key_terms") + } + }) + + t.Run("tier2", func(t *testing.T) { + p := tier2SystemPrompt() + if !strings.Contains(p, "title") { + t.Error("tier2 prompt should mention 'title'") + } + if !strings.Contains(p, "sections") { + t.Error("tier2 prompt should mention 'sections'") + } + }) + + t.Run("user", func(t *testing.T) { + p := tierUserPrompt("hello world") + if !strings.Contains(p, "hello world") { + t.Error("user prompt should contain the input text") + } + if !strings.Contains(p, "Decompose") { + t.Error("user prompt should contain 'Decompose'") + } + }) +} + +// === Input Normalization Tests === + +func TestDecompDetectFormat(t *testing.T) { + cases := []struct { + name string + input string + expect string + }{ + {"markdown_h1", "# Title\nSome content", "markdown"}, + {"markdown_h2", "Intro\n## Section\nContent", "markdown"}, + {"markdown_code", "Some text\n```go\nfmt.Println()\n```", "markdown"}, + {"conversation", "Human: hello\nAssistant: hi", "conversation"}, + {"plaintext", "Just some regular text with no special markers.", "plaintext"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := detectFormat(tc.input) + if got != tc.expect { + t.Errorf("detectFormat(%q) = %q, want %q", tc.name, got, tc.expect) + } + }) + } +} + +// === Tier Flag Parsing Tests === + +func TestDecompParseTierFlag(t *testing.T) { + cases := []struct { + flag string + want []int + wantErr bool + }{ + {"all", []int{0, 1, 2, 3}, false}, + {"0", []int{0}, false}, + {"0,1", []int{0, 1}, false}, + {"2,3", []int{2, 3}, false}, + {"0,1,2,3", []int{0, 1, 2, 3}, false}, + {"invalid", nil, true}, + {"0,5", nil, true}, + } + for _, tc := range cases { + t.Run(tc.flag, func(t *testing.T) { + got, err := parseTierFlag(tc.flag) + if tc.wantErr { + if err == nil { + t.Errorf("expected error for %q", tc.flag) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tc.flag, err) + } + if len(got) != len(tc.want) { + t.Fatalf("len mismatch: got %v, want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("tier[%d] = %d, want %d", i, got[i], tc.want[i]) + } + } + }) + } +} + +// === DecompositionResult Round-trip Test === + +func TestDecompResultRoundTrip(t *testing.T) { + result := &DecompositionResult{ + InputHash: "abcdef123456", + InputSize: 1024, + InputFormat: "markdown", + Tier0: &Tier0Result{Summary: "Test summary."}, + Tier1: &Tier1Result{ + Summary: "A longer test summary with details.", + KeyTerms: []string{"test", "summary"}, + }, + Metrics: DecompMetrics{ + Tier0Tokens: 10, + Tier0LatencyMs: 50, + TotalLatencyMs: 150, + CompressionRatio: 10.24, + }, + } + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded DecompositionResult + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.InputHash != result.InputHash { + t.Errorf("hash mismatch: %s vs %s", decoded.InputHash, result.InputHash) + } + if decoded.Tier0.Summary != result.Tier0.Summary { + t.Errorf("tier0 summary mismatch") + } + if decoded.Tier1.Summary != result.Tier1.Summary { + t.Errorf("tier1 summary mismatch") + } + if decoded.Metrics.CompressionRatio != result.Metrics.CompressionRatio { + t.Errorf("compression ratio mismatch") + } +} + +// === Event Callback Test === + +func TestDecompEventConstants(t *testing.T) { + // Verify event constants are non-empty and follow naming convention + events := []string{ + DecompEventStart, + DecompEventTierStart, + DecompEventTierComplete, + DecompEventComplete, + DecompEventError, + } + for _, e := range events { + if e == "" { + t.Error("event constant should not be empty") + } + if !strings.HasPrefix(e, "decompose.") { + t.Errorf("event %q should start with 'decompose.'", e) + } + } +} + +// === E2: DecompositionRunner Unit Tests (Mock Ollama) === + +// mockOllamaResponse builds an agentChatResponse JSON with the given content string. +func mockOllamaResponse(content string) []byte { + resp := agentChatResponse{ + Model: "test-model", + Done: true, + } + resp.Message.Role = "assistant" + resp.Message.Content = content + data, _ := json.Marshal(resp) + return data +} + +// tierResponseForSystemPrompt returns appropriate mock JSON based on the system prompt content. +func tierResponseForSystemPrompt(sysPrompt string) string { + if strings.Contains(sysPrompt, "one sentence") { + return `{"summary": "A concise one-sentence summary of the input."}` + } + if strings.Contains(sysPrompt, "paragraph") && strings.Contains(sysPrompt, "key_terms") { + return `{"summary": "A detailed paragraph summary covering the main points of the input text.", "key_terms": ["testing", "decomposition", "pipeline"]}` + } + if strings.Contains(sysPrompt, "title") && strings.Contains(sysPrompt, "sections") { + return `{"title": "Test Document", "type": "knowledge", "tags": ["test", "mock"], "summary": "A structured document from the input.", "sections": [{"heading": "Introduction", "content": "This is the intro section."}]}` + } + // Fallback + return `{"summary": "fallback"}` +} + +// TestDecompRunnerPromptConstruction verifies that the runner sends correct prompts to Ollama. +func TestDecompRunnerPromptConstruction(t *testing.T) { + var mu sync.Mutex + var captured []agentChatRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req agentChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("failed to decode request: %v", err) + http.Error(w, "bad request", 400) + return + } + mu.Lock() + captured = append(captured, req) + mu.Unlock() + + // Find system prompt to determine tier + var sysPrompt string + for _, m := range req.Messages { + if m.Role == "system" { + sysPrompt = m.Content + break + } + } + content := tierResponseForSystemPrompt(sysPrompt) + w.Write(mockOllamaResponse(content)) + })) + defer server.Close() + + harness := NewAgentHarness(AgentHarnessConfig{ + OllamaURL: server.URL, + Model: "test-model", + }) + runner := NewDecompositionRunner(harness, nil) + + input := &DecompInput{Text: "The quick brown fox jumps over the lazy dog.", Format: "plaintext", ByteSize: 44} + _, err := runner.Run(context.Background(), input) + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + // Should have exactly 3 LLM calls (tier 0, 1, 2 — tier 3 is passthrough) + mu.Lock() + defer mu.Unlock() + if len(captured) != 3 { + t.Fatalf("expected 3 LLM calls, got %d", len(captured)) + } + + // Tier 0 checks + req0 := captured[0] + if req0.Format != "json" { + t.Errorf("tier 0: expected format 'json', got %q", req0.Format) + } + if req0.Think != false { + t.Errorf("tier 0: expected think=false") + } + var sys0, user0 string + for _, m := range req0.Messages { + if m.Role == "system" { + sys0 = m.Content + } + if m.Role == "user" { + user0 = m.Content + } + } + if !strings.Contains(sys0, "one sentence") { + t.Errorf("tier 0 system prompt missing 'one sentence': %s", sys0) + } + if !strings.Contains(user0, "The quick brown fox") { + t.Errorf("tier 0 user prompt missing input text: %s", user0) + } + + // Tier 1 checks + var sys1 string + for _, m := range captured[1].Messages { + if m.Role == "system" { + sys1 = m.Content + } + } + if !strings.Contains(sys1, "paragraph") { + t.Errorf("tier 1 system prompt missing 'paragraph': %s", sys1) + } + if !strings.Contains(sys1, "key_terms") { + t.Errorf("tier 1 system prompt missing 'key_terms': %s", sys1) + } + + // Tier 2 checks + var sys2 string + for _, m := range captured[2].Messages { + if m.Role == "system" { + sys2 = m.Content + } + } + if !strings.Contains(sys2, "title") { + t.Errorf("tier 2 system prompt missing 'title': %s", sys2) + } + if !strings.Contains(sys2, "sections") { + t.Errorf("tier 2 system prompt missing 'sections': %s", sys2) + } + if !strings.Contains(sys2, "tags") { + t.Errorf("tier 2 system prompt missing 'tags': %s", sys2) + } +} + +// TestDecompRunnerResultAssembly verifies that the runner assembles results correctly from mock responses. +func TestDecompRunnerResultAssembly(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req agentChatRequest + json.NewDecoder(r.Body).Decode(&req) + + var sysPrompt string + for _, m := range req.Messages { + if m.Role == "system" { + sysPrompt = m.Content + break + } + } + content := tierResponseForSystemPrompt(sysPrompt) + w.Write(mockOllamaResponse(content)) + })) + defer server.Close() + + harness := NewAgentHarness(AgentHarnessConfig{ + OllamaURL: server.URL, + Model: "test-model", + }) + runner := NewDecompositionRunner(harness, nil) + + inputText := "Some important text that needs decomposition into tiers." + input := &DecompInput{Text: inputText, Format: "plaintext", ByteSize: len(inputText)} + result, err := runner.Run(context.Background(), input) + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + // Tier 0 + if result.Tier0 == nil { + t.Fatal("expected Tier0 to be populated") + } + if result.Tier0.Summary != "A concise one-sentence summary of the input." { + t.Errorf("tier 0 summary mismatch: %q", result.Tier0.Summary) + } + + // Tier 1 + if result.Tier1 == nil { + t.Fatal("expected Tier1 to be populated") + } + if !strings.Contains(result.Tier1.Summary, "detailed paragraph") { + t.Errorf("tier 1 summary mismatch: %q", result.Tier1.Summary) + } + if len(result.Tier1.KeyTerms) != 3 { + t.Errorf("expected 3 key terms, got %d", len(result.Tier1.KeyTerms)) + } + + // Tier 2 + if result.Tier2 == nil { + t.Fatal("expected Tier2 to be populated") + } + if result.Tier2.Title != "Test Document" { + t.Errorf("tier 2 title mismatch: %q", result.Tier2.Title) + } + if result.Tier2.Type != "knowledge" { + t.Errorf("tier 2 type mismatch: %q", result.Tier2.Type) + } + if len(result.Tier2.Tags) != 2 { + t.Errorf("expected 2 tags, got %d", len(result.Tier2.Tags)) + } + if len(result.Tier2.Sections) != 1 { + t.Errorf("expected 1 section, got %d", len(result.Tier2.Sections)) + } + if result.Tier2.Sections[0].Heading != "Introduction" { + t.Errorf("section heading mismatch: %q", result.Tier2.Sections[0].Heading) + } + + // Tier 3 raw + if result.Tier3Raw != inputText { + t.Errorf("tier 3 raw mismatch: %q", result.Tier3Raw) + } + + // Metadata + if result.InputHash == "" { + t.Error("expected non-empty input hash") + } + if result.InputSize != len(inputText) { + t.Errorf("input size mismatch: %d vs %d", result.InputSize, len(inputText)) + } + if result.InputFormat != "plaintext" { + t.Errorf("input format mismatch: %q", result.InputFormat) + } + if result.Metrics.CompressionRatio <= 0 { + t.Error("expected positive compression ratio") + } + if result.Metrics.TotalLatencyMs < 0 { + t.Error("expected non-negative total latency") + } +} + +// TestDecompRunnerRetryOnMalformedJSON verifies that the runner retries once on invalid JSON. +func TestDecompRunnerRetryOnMalformedJSON(t *testing.T) { + var mu sync.Mutex + callCount := 0 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req agentChatRequest + json.NewDecoder(r.Body).Decode(&req) + + var sysPrompt string + for _, m := range req.Messages { + if m.Role == "system" { + sysPrompt = m.Content + break + } + } + + mu.Lock() + callCount++ + currentCall := callCount + mu.Unlock() + + // First call for tier 0: return invalid JSON. Second call (retry): return valid. + if currentCall == 1 && strings.Contains(sysPrompt, "one sentence") { + w.Write(mockOllamaResponse(`{this is not valid json`)) + return + } + + content := tierResponseForSystemPrompt(sysPrompt) + w.Write(mockOllamaResponse(content)) + })) + defer server.Close() + + harness := NewAgentHarness(AgentHarnessConfig{ + OllamaURL: server.URL, + Model: "test-model", + }) + runner := NewDecompositionRunner(harness, nil) + + input := &DecompInput{Text: "Retry test input text.", Format: "plaintext", ByteSize: 22} + result, err := runner.Run(context.Background(), input) + if err != nil { + t.Fatalf("Run should succeed after retry, got: %v", err) + } + + if result.Tier0 == nil { + t.Fatal("expected Tier0 to be populated after retry") + } + if result.Tier0.Summary == "" { + t.Error("expected non-empty tier 0 summary after retry") + } + + // Should have 4 calls: tier0 fail + tier0 retry + tier1 + tier2 + mu.Lock() + defer mu.Unlock() + if callCount != 4 { + t.Errorf("expected 4 LLM calls (1 fail + 1 retry + tier1 + tier2), got %d", callCount) + } +} + +// TestDecompRunnerEventCallbackSequence verifies the correct sequence of bus events. +func TestDecompRunnerEventCallbackSequence(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req agentChatRequest + json.NewDecoder(r.Body).Decode(&req) + + var sysPrompt string + for _, m := range req.Messages { + if m.Role == "system" { + sysPrompt = m.Content + break + } + } + content := tierResponseForSystemPrompt(sysPrompt) + w.Write(mockOllamaResponse(content)) + })) + defer server.Close() + + type eventRecord struct { + eventType string + payload map[string]interface{} + } + var mu sync.Mutex + var events []eventRecord + + callback := func(eventType string, payload map[string]interface{}) { + mu.Lock() + events = append(events, eventRecord{eventType, payload}) + mu.Unlock() + } + + harness := NewAgentHarness(AgentHarnessConfig{ + OllamaURL: server.URL, + Model: "test-model", + }) + runner := NewDecompositionRunner(harness, callback) + + input := &DecompInput{Text: "Event sequence test input.", Format: "plaintext", ByteSize: 26} + _, err := runner.Run(context.Background(), input) + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + // Expected sequence: + // decompose.start + // decompose.tier.start (tier=0), decompose.tier.complete (tier=0) + // decompose.tier.start (tier=1), decompose.tier.complete (tier=1) + // decompose.tier.start (tier=2), decompose.tier.complete (tier=2) + // decompose.complete + expectedTypes := []string{ + DecompEventStart, + DecompEventTierStart, DecompEventTierComplete, + DecompEventTierStart, DecompEventTierComplete, + DecompEventTierStart, DecompEventTierComplete, + DecompEventComplete, + } + + if len(events) != len(expectedTypes) { + t.Fatalf("expected %d events, got %d: %v", len(expectedTypes), len(events), func() []string { + var names []string + for _, e := range events { + names = append(names, e.eventType) + } + return names + }()) + } + + for i, expected := range expectedTypes { + if events[i].eventType != expected { + t.Errorf("event[%d]: expected %q, got %q", i, expected, events[i].eventType) + } + } + + // Verify tier numbers in tier.start/tier.complete events + tierEvents := []struct { + idx int + tier float64 + }{ + {1, 0}, {2, 0}, // tier 0 start/complete + {3, 1}, {4, 1}, // tier 1 start/complete + {5, 2}, {6, 2}, // tier 2 start/complete + } + for _, te := range tierEvents { + tierVal, ok := events[te.idx].payload["tier"] + if !ok { + t.Errorf("event[%d] missing 'tier' in payload", te.idx) + continue + } + // payload values are interface{}, tier is int but compare as float64 for JSON compat + if tierNum, ok := tierVal.(int); ok { + if float64(tierNum) != te.tier { + t.Errorf("event[%d]: expected tier=%v, got %v", te.idx, te.tier, tierNum) + } + } else { + t.Errorf("event[%d]: tier is not int: %T", te.idx, tierVal) + } + } +} + +// TestDecompRunnerSelectiveTiers verifies that only requested tiers are executed. +func TestDecompRunnerSelectiveTiers(t *testing.T) { + var mu sync.Mutex + callCount := 0 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req agentChatRequest + json.NewDecoder(r.Body).Decode(&req) + + mu.Lock() + callCount++ + mu.Unlock() + + var sysPrompt string + for _, m := range req.Messages { + if m.Role == "system" { + sysPrompt = m.Content + break + } + } + content := tierResponseForSystemPrompt(sysPrompt) + w.Write(mockOllamaResponse(content)) + })) + defer server.Close() + + type eventRecord struct { + eventType string + payload map[string]interface{} + } + var eventMu sync.Mutex + var events []eventRecord + + callback := func(eventType string, payload map[string]interface{}) { + eventMu.Lock() + events = append(events, eventRecord{eventType, payload}) + eventMu.Unlock() + } + + harness := NewAgentHarness(AgentHarnessConfig{ + OllamaURL: server.URL, + Model: "test-model", + }) + runner := NewDecompositionRunner(harness, callback) + runner.tiers = []int{0, 2} // Skip tier 1, no tier 3 + + inputText := "Selective tier test input." + input := &DecompInput{Text: inputText, Format: "plaintext", ByteSize: len(inputText)} + result, err := runner.Run(context.Background(), input) + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + // Tier 0 should be populated + if result.Tier0 == nil { + t.Error("expected Tier0 to be populated") + } + + // Tier 1 should be nil (skipped) + if result.Tier1 != nil { + t.Error("expected Tier1 to be nil (skipped)") + } + + // Tier 2 should be populated + if result.Tier2 == nil { + t.Error("expected Tier2 to be populated") + } + + // Tier 3 raw should be empty (tier 3 not requested) + if result.Tier3Raw != "" { + t.Errorf("expected empty Tier3Raw when tier 3 not requested, got %q", result.Tier3Raw) + } + + // Only 2 LLM calls (tier 0 and tier 2) + mu.Lock() + if callCount != 2 { + t.Errorf("expected 2 LLM calls, got %d", callCount) + } + mu.Unlock() + + // Verify events: start, tier0 start/complete, tier2 start/complete, complete + eventMu.Lock() + defer eventMu.Unlock() + + expectedTypes := []string{ + DecompEventStart, + DecompEventTierStart, DecompEventTierComplete, + DecompEventTierStart, DecompEventTierComplete, + DecompEventComplete, + } + if len(events) != len(expectedTypes) { + var names []string + for _, e := range events { + names = append(names, e.eventType) + } + t.Fatalf("expected %d events, got %d: %v", len(expectedTypes), len(events), names) + } + + for i, expected := range expectedTypes { + if events[i].eventType != expected { + t.Errorf("event[%d]: expected %q, got %q", i, expected, events[i].eventType) + } + } + + // Verify tier numbers: should be 0 and 2, not 1 + tierStartEvents := []struct { + idx int + tier int + }{ + {1, 0}, {2, 0}, // tier 0 + {3, 2}, {4, 2}, // tier 2 (not tier 1) + } + for _, te := range tierStartEvents { + tierVal, ok := events[te.idx].payload["tier"] + if !ok { + t.Errorf("event[%d] missing 'tier' in payload", te.idx) + continue + } + if tierNum, ok := tierVal.(int); ok { + if tierNum != te.tier { + t.Errorf("event[%d]: expected tier=%d, got %d", te.idx, te.tier, tierNum) + } + } + } +} + +// === Wave 3 B3: Formatter Tests === + +func TestDecompFormatterOutput(t *testing.T) { + result := &DecompositionResult{ + InputHash: "abc123def456", + InputSize: 2847, + InputFormat: "markdown", + Tier0: &Tier0Result{Summary: "The CogOS kernel agent runs a homeostatic observation loop."}, + Tier1: &Tier1Result{ + Summary: "The CogOS kernel agent implements a homeostatic loop that observes workspace state.", + KeyTerms: []string{"kernel", "homeostatic", "agent", "observation"}, + }, + Tier2: &Tier2Result{ + Title: "CogOS Kernel Agent Homeostatic Loop", + Type: "architecture", + Tags: []string{"kernel", "agent", "homeostatic"}, + Summary: "Detailed architecture overview.", + Sections: []Tier2Section{ + {Heading: "Overview", Content: "The agent observes workspace state."}, + {Heading: "Design", Content: "Uses a reconciliation loop pattern."}, + }, + Refs: []Tier2Ref{ + {URI: "cog://mem/semantic/architecture/kernel", Relation: "extends"}, + }, + }, + Tier3Raw: "raw content here that is stored but not displayed in the output", + Metrics: DecompMetrics{ + Tier0Tokens: 15, + Tier0LatencyMs: 420, + Tier1Tokens: 89, + Tier1LatencyMs: 1200, + Tier2Tokens: 342, + Tier2LatencyMs: 3800, + TotalLatencyMs: 5400, + CompressionRatio: 190, + }, + } + + var buf strings.Builder + printDecompResultTo(&buf, result) + output := buf.String() + + // Header checks + if !strings.Contains(output, "Decomposition Results") { + t.Error("expected 'Decomposition Results' header") + } + if !strings.Contains(output, "2,847 bytes") { + t.Error("expected formatted byte count '2,847 bytes'") + } + if !strings.Contains(output, "abc123def456") { + t.Error("expected input hash in output") + } + + // Tier headers + if !strings.Contains(output, "T0: Sentence") { + t.Error("expected T0 tier header") + } + if !strings.Contains(output, "15 tok") { + t.Error("expected tier 0 token count") + } + if !strings.Contains(output, "420ms") { + t.Error("expected tier 0 latency") + } + + if !strings.Contains(output, "T1: Paragraph") { + t.Error("expected T1 tier header") + } + if !strings.Contains(output, "89 tok") { + t.Error("expected tier 1 token count") + } + if !strings.Contains(output, "1.2s") { + t.Error("expected tier 1 latency formatted as seconds") + } + if !strings.Contains(output, "Key terms:") { + t.Error("expected key terms label") + } + + if !strings.Contains(output, "T2: CogDoc") { + t.Error("expected T2 tier header") + } + if !strings.Contains(output, "342 tok") { + t.Error("expected tier 2 token count") + } + if !strings.Contains(output, "Title:") { + t.Error("expected Title label in T2") + } + if !strings.Contains(output, "[Overview]") { + t.Error("expected section heading inline") + } + if !strings.Contains(output, "cog://mem/semantic/architecture/kernel") { + t.Error("expected ref URI in output") + } + + if !strings.Contains(output, "T3: Raw") { + t.Error("expected T3 tier header") + } + if !strings.Contains(output, "stored, not displayed") { + t.Error("expected 'stored, not displayed' message for T3") + } + + // Metrics footer + if !strings.Contains(output, "Metrics") { + t.Error("expected Metrics footer") + } + if !strings.Contains(output, "5.4s") { + t.Error("expected total latency in footer") + } + if !strings.Contains(output, "Compression: 190:1") { + t.Error("expected compression ratio in footer") + } +} + +func TestDecompFormatterPartialTiers(t *testing.T) { + // Only T0, no other tiers + result := &DecompositionResult{ + InputHash: "deadbeef1234", + InputSize: 100, + InputFormat: "plaintext", + Tier0: &Tier0Result{Summary: "Simple summary."}, + Metrics: DecompMetrics{ + Tier0Tokens: 5, + Tier0LatencyMs: 100, + TotalLatencyMs: 100, + }, + } + + var buf strings.Builder + printDecompResultTo(&buf, result) + output := buf.String() + + if !strings.Contains(output, "T0: Sentence") { + t.Error("expected T0 header") + } + if strings.Contains(output, "T1:") { + t.Error("should not contain T1 when not present") + } + if strings.Contains(output, "T2:") { + t.Error("should not contain T2 when not present") + } + if strings.Contains(output, "T3:") { + t.Error("should not contain T3 when not present") + } + // No compression ratio when zero + if strings.Contains(output, "Compression:") { + t.Error("should not show compression when ratio is 0") + } +} + +func TestDecompFormatLatency(t *testing.T) { + cases := []struct { + ms int64 + want string + }{ + {0, "0ms"}, + {420, "420ms"}, + {999, "999ms"}, + {1000, "1.0s"}, + {1200, "1.2s"}, + {5400, "5.4s"}, + {10500, "10.5s"}, + } + for _, tc := range cases { + got := formatLatency(tc.ms) + if got != tc.want { + t.Errorf("formatLatency(%d) = %q, want %q", tc.ms, got, tc.want) + } + } +} + +func TestDecompFormatBytes(t *testing.T) { + cases := []struct { + n int + want string + }{ + {0, "0"}, + {42, "42"}, + {999, "999"}, + {1000, "1,000"}, + {2847, "2,847"}, + {12345, "12,345"}, + {1234567, "1,234,567"}, + } + for _, tc := range cases { + got := decompFormatBytes(tc.n) + if got != tc.want { + t.Errorf("decompFormatBytes(%d) = %q, want %q", tc.n, got, tc.want) + } + } +} + +// === Wave 3 D2: File Event Callback Tests === + +func TestDecompFileEventCallback(t *testing.T) { + tmpDir := t.TempDir() + + // Create .cog directory structure + cogDir := filepath.Join(tmpDir, ".cog") + if err := os.MkdirAll(cogDir, 0755); err != nil { + t.Fatalf("mkdir .cog: %v", err) + } + + callback := newFileEventCallback(tmpDir) + + // Emit a few events + callback(DecompEventStart, map[string]interface{}{ + "input_hash": "test123", + "input_size": 100, + }) + callback(DecompEventTierComplete, map[string]interface{}{ + "tier": 0, + "latency_ms": 42, + }) + callback(DecompEventComplete, map[string]interface{}{ + "input_hash": "test123", + "total_latency": 100, + }) + + // Read the events file + eventsPath := filepath.Join(tmpDir, ".cog", ".state", "buses", "decompose", "events.jsonl") + data, err := os.ReadFile(eventsPath) + if err != nil { + t.Fatalf("read events file: %v", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 3 { + t.Fatalf("expected 3 event lines, got %d", len(lines)) + } + + // Parse first event + var evt decompBusEvent + if err := json.Unmarshal([]byte(lines[0]), &evt); err != nil { + t.Fatalf("unmarshal event: %v", err) + } + if evt.Type != DecompEventStart { + t.Errorf("expected type %q, got %q", DecompEventStart, evt.Type) + } + if evt.From != "decompose" { + t.Errorf("expected from 'decompose', got %q", evt.From) + } + if evt.Ts == "" { + t.Error("expected non-empty timestamp") + } + if evt.Payload["input_hash"] != "test123" { + t.Errorf("expected input_hash 'test123', got %v", evt.Payload["input_hash"]) + } + + // Parse last event + var lastEvt decompBusEvent + if err := json.Unmarshal([]byte(lines[2]), &lastEvt); err != nil { + t.Fatalf("unmarshal last event: %v", err) + } + if lastEvt.Type != DecompEventComplete { + t.Errorf("expected type %q, got %q", DecompEventComplete, lastEvt.Type) + } +} + +// === D4: Quality Metrics Tests === + +func TestDecompCosineSimilarity(t *testing.T) { + // Identical vectors should give similarity ~1.0 + a := []float32{1, 0, 0, 0} + b := []float32{1, 0, 0, 0} + sim := cosineSimilarity(a, b) + if sim < 0.999 || sim > 1.001 { + t.Errorf("identical vectors: expected ~1.0, got %f", sim) + } + + // Orthogonal vectors should give similarity ~0.0 + c := []float32{1, 0, 0, 0} + d := []float32{0, 1, 0, 0} + sim = cosineSimilarity(c, d) + if sim < -0.001 || sim > 0.001 { + t.Errorf("orthogonal vectors: expected ~0.0, got %f", sim) + } + + // Opposite vectors should give similarity ~-1.0 + e := []float32{1, 0, 0, 0} + f := []float32{-1, 0, 0, 0} + sim = cosineSimilarity(e, f) + if sim < -1.001 || sim > -0.999 { + t.Errorf("opposite vectors: expected ~-1.0, got %f", sim) + } + + // Empty vectors should give 0.0 + sim = cosineSimilarity(nil, a) + if sim != 0.0 { + t.Errorf("nil vector: expected 0.0, got %f", sim) + } + sim = cosineSimilarity([]float32{}, a) + if sim != 0.0 { + t.Errorf("empty vector: expected 0.0, got %f", sim) + } + + // Zero vector should give 0.0 + sim = cosineSimilarity([]float32{0, 0, 0}, a) + if sim != 0.0 { + t.Errorf("zero vector: expected 0.0, got %f", sim) + } +} + +func TestDecompComputeQuality(t *testing.T) { + result := &DecompositionResult{ + Metrics: DecompMetrics{CompressionRatio: 42.5}, + Embeddings: &DecompEmbeddings{ + Tier0_128: make([]float32, 128), + Tier1_128: make([]float32, 128), + Tier2_128: make([]float32, 128), + }, + } + // Set some values so cosine similarity is meaningful + result.Embeddings.Tier0_128[0] = 1.0 + result.Embeddings.Tier1_128[0] = 0.8 + result.Embeddings.Tier1_128[1] = 0.6 + result.Embeddings.Tier2_128[0] = 1.0 + + q := computeQuality(result) + if q.CompressionRatio != 42.5 { + t.Errorf("compression ratio mismatch: got %f", q.CompressionRatio) + } + if q.Tier0Fidelity < 0.99 { + t.Errorf("tier0 fidelity should be ~1.0 (parallel vectors), got %f", q.Tier0Fidelity) + } + if q.Tier1Fidelity < 0.5 || q.Tier1Fidelity > 0.95 { + t.Errorf("tier1 fidelity should be moderate, got %f", q.Tier1Fidelity) + } + if !q.SchemaConformant { + t.Error("default schema conformant should be true") + } +} + +func TestDecompComputeQualityNoEmbeddings(t *testing.T) { + result := &DecompositionResult{ + Metrics: DecompMetrics{CompressionRatio: 10.0}, + } + q := computeQuality(result) + if q.Tier0Fidelity != 0.0 { + t.Errorf("expected 0.0 fidelity without embeddings, got %f", q.Tier0Fidelity) + } + if q.Tier1Fidelity != 0.0 { + t.Errorf("expected 0.0 fidelity without embeddings, got %f", q.Tier1Fidelity) + } +} + +// === E4: Bus Event File Sequence Test === + +func TestDecompBusEventFileSequence(t *testing.T) { + // Create temp workspace with .cog/ directory structure + tmpDir := t.TempDir() + cogDir := filepath.Join(tmpDir, ".cog") + if err := os.MkdirAll(cogDir, 0755); err != nil { + t.Fatalf("mkdir .cog: %v", err) + } + + // Set up a mock Ollama server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req agentChatRequest + json.NewDecoder(r.Body).Decode(&req) + + var sysPrompt string + for _, m := range req.Messages { + if m.Role == "system" { + sysPrompt = m.Content + break + } + } + content := tierResponseForSystemPrompt(sysPrompt) + w.Write(mockOllamaResponse(content)) + })) + defer server.Close() + + // Create runner with the file-based event callback + harness := NewAgentHarness(AgentHarnessConfig{ + OllamaURL: server.URL, + Model: "test-model", + }) + callback := newFileEventCallback(tmpDir) + runner := NewDecompositionRunner(harness, callback) + + // Run decomposition + inputText := "The bus event sequence test verifies that decomposition events are written to JSONL files in the correct order." + input := &DecompInput{Text: inputText, Format: "plaintext", ByteSize: len(inputText)} + _, err := runner.Run(context.Background(), input) + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + // Read back the JSONL file + eventsPath := filepath.Join(tmpDir, ".cog", ".state", "buses", "decompose", "events.jsonl") + data, err := os.ReadFile(eventsPath) + if err != nil { + t.Fatalf("read events file: %v", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + + // Expected event sequence: + // decompose.start + // decompose.tier.start (tier=0) + // decompose.tier.complete (tier=0) + // decompose.tier.start (tier=1) + // decompose.tier.complete (tier=1) + // decompose.tier.start (tier=2) + // decompose.tier.complete (tier=2) + // decompose.complete + expectedTypes := []string{ + DecompEventStart, + DecompEventTierStart, DecompEventTierComplete, + DecompEventTierStart, DecompEventTierComplete, + DecompEventTierStart, DecompEventTierComplete, + DecompEventComplete, + } + + if len(lines) != len(expectedTypes) { + var gotTypes []string + for _, line := range lines { + var evt decompBusEvent + json.Unmarshal([]byte(line), &evt) + gotTypes = append(gotTypes, evt.Type) + } + t.Fatalf("expected %d event lines, got %d: %v", len(expectedTypes), len(lines), gotTypes) + } + + // Parse and verify each event + var events []decompBusEvent + for i, line := range lines { + var evt decompBusEvent + if err := json.Unmarshal([]byte(line), &evt); err != nil { + t.Fatalf("unmarshal event line %d: %v", i, err) + } + events = append(events, evt) + } + + // Verify event types in order + for i, expected := range expectedTypes { + if events[i].Type != expected { + t.Errorf("event[%d]: expected type %q, got %q", i, expected, events[i].Type) + } + } + + // Verify all events have common fields + for i, evt := range events { + if evt.Ts == "" { + t.Errorf("event[%d]: expected non-empty timestamp", i) + } + if evt.From != "decompose" { + t.Errorf("event[%d]: expected from='decompose', got %q", i, evt.From) + } + } + + // Verify tier numbers in tier.start/tier.complete events + tierChecks := []struct { + idx int + tier float64 + }{ + {1, 0}, {2, 0}, // tier 0 start/complete + {3, 1}, {4, 1}, // tier 1 start/complete + {5, 2}, {6, 2}, // tier 2 start/complete + } + for _, tc := range tierChecks { + tierVal, ok := events[tc.idx].Payload["tier"] + if !ok { + t.Errorf("event[%d] missing 'tier' in payload", tc.idx) + continue + } + // JSON numbers decode as float64 + tierNum, ok := tierVal.(float64) + if !ok { + t.Errorf("event[%d]: tier is not float64: %T", tc.idx, tierVal) + continue + } + if tierNum != tc.tier { + t.Errorf("event[%d]: expected tier=%v, got %v", tc.idx, tc.tier, tierNum) + } + } + + // Verify tier.complete events have latency_ms + for _, idx := range []int{2, 4, 6} { + if _, ok := events[idx].Payload["latency_ms"]; !ok { + t.Errorf("event[%d] (tier.complete) missing 'latency_ms' in payload", idx) + } + } + + // Verify decompose.complete has expected fields from enriched payload + completePayload := events[7].Payload + requiredFields := []string{"input_hash", "total_latency", "tier0_tokens", "tier0_latency_ms", + "tier1_tokens", "tier1_latency_ms", "tier2_tokens", "tier2_latency_ms", + "compression_ratio", "schema_conformant"} + for _, field := range requiredFields { + if _, ok := completePayload[field]; !ok { + t.Errorf("decompose.complete missing field %q in payload", field) + } + } + + // Verify start event has input metadata + startPayload := events[0].Payload + if _, ok := startPayload["input_hash"]; !ok { + t.Error("decompose.start missing 'input_hash'") + } + if _, ok := startPayload["input_size"]; !ok { + t.Error("decompose.start missing 'input_size'") + } +} + +func TestDecompFileEventCallbackNoopOnBadDir(t *testing.T) { + // Use a path that cannot be created (file as parent) + tmpDir := t.TempDir() + blocker := filepath.Join(tmpDir, "blocker") + if err := os.WriteFile(blocker, []byte("not a dir"), 0644); err != nil { + t.Fatalf("write blocker: %v", err) + } + + // This should return a no-op callback without panic + callback := newFileEventCallback(blocker) + // Calling the no-op callback should not panic + callback("test.event", map[string]interface{}{"key": "value"}) +} + +// TestDecompAcknowledgeProposal verifies that the acknowledge_proposal tool +// correctly transitions a proposal from pending to the requested status. +func TestDecompAcknowledgeProposal(t *testing.T) { + tmp := t.TempDir() + dir := filepath.Join(tmp, proposalsDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + // Write a pending proposal + proposal := `--- +id: 20260415-120000 +type: observation +title: "Test proposal" +target: "" +urgency: 0.5 +created: 2026-04-15T12:00:00Z +status: pending +--- + +# Test proposal + +This is a test proposal body. +` + filename := "20260415-120000-test-proposal.md" + if err := os.WriteFile(filepath.Join(dir, filename), []byte(proposal), 0o644); err != nil { + t.Fatal(err) + } + + // Call the acknowledge function + fn := newAcknowledgeProposalFunc(tmp) + args, _ := json.Marshal(map[string]string{ + "filename": filename, + "status": "acknowledged", + }) + result, err := fn(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp map[string]string + if err := json.Unmarshal(result, &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp["status"] != "acknowledged" { + t.Errorf("expected status=acknowledged, got %q", resp["status"]) + } + + // Verify the file was updated + updated, err := os.ReadFile(filepath.Join(dir, filename)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(updated), "status: pending") { + t.Error("file still contains 'status: pending'") + } + if !strings.Contains(string(updated), "status: acknowledged") { + t.Error("file does not contain 'status: acknowledged'") + } + + // Test with a note + fn2 := newAcknowledgeProposalFunc(tmp) + args2, _ := json.Marshal(map[string]string{ + "filename": filename, + "status": "approved", + "note": "Looks good, proceed.", + }) + result2, err := fn2(context.Background(), args2) + if err != nil { + t.Fatalf("unexpected error on second call: %v", err) + } + var resp2 map[string]string + json.Unmarshal(result2, &resp2) + if resp2["status"] != "approved" { + t.Errorf("expected status=approved, got %q", resp2["status"]) + } + + // Verify note was appended + final, _ := os.ReadFile(filepath.Join(dir, filename)) + if !strings.Contains(string(final), "## Operator Note") { + t.Error("file does not contain operator note header") + } + if !strings.Contains(string(final), "Looks good, proceed.") { + t.Error("file does not contain the note text") + } +} diff --git a/decompose_tui.go b/decompose_tui.go new file mode 100644 index 0000000..93c1dc8 --- /dev/null +++ b/decompose_tui.go @@ -0,0 +1,351 @@ +// decompose_tui.go — Bubbletea workbench for the decomposition pipeline. +// Launched via: cog decompose --workbench + +package main + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// === Styles === + +var ( + // Tier header colors match the CLI formatter. + tierColors = [4]lipgloss.Color{ + lipgloss.Color("#00FFFF"), // T0 cyan + lipgloss.Color("#00FF00"), // T1 green + lipgloss.Color("#FFFF00"), // T2 yellow + lipgloss.Color("#5555FF"), // T3 blue + } + + tierNames = [4]string{"T0 Sentence", "T1 Paragraph", "T2 CogDoc", "T3 Raw"} + + activeBorderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FFFF")) + + inactiveBorderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(mutedColor) + + decompTitleBarStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#7C3AED")). + Padding(0, 1) + + metricsBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#D1D5DB")). + Background(lipgloss.Color("#374151")). + Padding(0, 1) + + helpBarStyle = lipgloss.NewStyle(). + Foreground(mutedColor) + + spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} +) + +// === Messages === + +type decompDoneMsg struct { + result *DecompositionResult + err error +} + +// === Model === + +type decompTUIModel struct { + input *DecompInput + result *DecompositionResult + runner *DecompositionRunner + viewports [4]viewport.Model + active int // 0-3, which viewport has focus + running bool + err error + width int + height int + ready bool +} + +func initialDecompTUIModel(input *DecompInput, result *DecompositionResult, runner *DecompositionRunner) decompTUIModel { + var vps [4]viewport.Model + for i := range vps { + vps[i] = viewport.New(40, 10) + } + + m := decompTUIModel{ + input: input, + result: result, + runner: runner, + viewports: vps, + active: 0, + } + if result != nil { + m.populateViewports() + } + return m +} + +func (m *decompTUIModel) populateViewports() { + r := m.result + if r == nil { + return + } + + // T0 + if r.Tier0 != nil { + m.viewports[0].SetContent(r.Tier0.Summary) + } else { + m.viewports[0].SetContent("(not generated)") + } + + // T1 + if r.Tier1 != nil { + var sb strings.Builder + sb.WriteString(r.Tier1.Summary) + if len(r.Tier1.KeyTerms) > 0 { + sb.WriteString("\n\nKey terms: ") + sb.WriteString(strings.Join(r.Tier1.KeyTerms, ", ")) + } + m.viewports[1].SetContent(sb.String()) + } else { + m.viewports[1].SetContent("(not generated)") + } + + // T2 + if r.Tier2 != nil { + var sb strings.Builder + fmt.Fprintf(&sb, "Title: %s\n", r.Tier2.Title) + fmt.Fprintf(&sb, "Type: %s\n", r.Tier2.Type) + fmt.Fprintf(&sb, "Tags: %s\n", strings.Join(r.Tier2.Tags, ", ")) + sb.WriteString("\n") + sb.WriteString(r.Tier2.Summary) + for _, s := range r.Tier2.Sections { + fmt.Fprintf(&sb, "\n\n## %s\n%s", s.Heading, s.Content) + } + if len(r.Tier2.Refs) > 0 { + sb.WriteString("\n\nRefs:") + for _, ref := range r.Tier2.Refs { + fmt.Fprintf(&sb, "\n %s (%s)", ref.URI, ref.Relation) + } + } + m.viewports[2].SetContent(sb.String()) + } else { + m.viewports[2].SetContent("(not generated)") + } + + // T3 + if r.Tier3Raw != "" { + m.viewports[3].SetContent(r.Tier3Raw) + } else { + m.viewports[3].SetContent("(not generated)") + } +} + +// === Bubbletea Interface === + +func (m decompTUIModel) Init() tea.Cmd { + return nil +} + +func (m decompTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "tab": + m.active = (m.active + 1) % 4 + return m, nil + case "shift+tab": + m.active = (m.active + 3) % 4 + return m, nil + case "1": + m.active = 0 + return m, nil + case "2": + m.active = 1 + return m, nil + case "3": + m.active = 2 + return m, nil + case "4": + m.active = 3 + return m, nil + case "r": + if !m.running { + m.running = true + m.err = nil + return m, m.rerunCmd() + } + return m, nil + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.ready = true + m.resizeViewports() + return m, nil + + case decompDoneMsg: + m.running = false + if msg.err != nil { + m.err = msg.err + } else { + m.result = msg.result + m.populateViewports() + } + return m, nil + } + + // Forward scroll events to the active viewport. + var cmd tea.Cmd + m.viewports[m.active], cmd = m.viewports[m.active].Update(msg) + return m, cmd +} + +func (m *decompTUIModel) rerunCmd() tea.Cmd { + return func() tea.Msg { + result, err := m.runner.Run(context.Background(), m.input) + return decompDoneMsg{result: result, err: err} + } +} + +func (m *decompTUIModel) resizeViewports() { + if m.width == 0 || m.height == 0 { + return + } + // Layout: title(1) + grid(2 rows) + metrics(1) + help(1) + borders + // Each panel has 2 lines of border (top+bottom) and 1 line header + panelW := (m.width - 1) / 2 // two columns, 1 char divider + contentW := panelW - 4 // border(2) + padding(2) + if contentW < 10 { + contentW = 10 + } + + // Vertical: title=1, metrics=1, help=1, two rows of panels + usableH := m.height - 3 // title + metrics + help + panelH := usableH / 2 + contentH := panelH - 3 // border(2) + header(1) + if contentH < 2 { + contentH = 2 + } + + for i := range m.viewports { + m.viewports[i].Width = contentW + m.viewports[i].Height = contentH + } +} + +func (m decompTUIModel) View() string { + if !m.ready { + return "Initializing..." + } + + var sb strings.Builder + + // Title bar + sourceName := "stdin" + if m.input.SourceURI != "" { + sourceName = filepath.Base(strings.TrimPrefix(m.input.SourceURI, "file://")) + } + title := fmt.Sprintf(" Decomposition Workbench — %s (%s bytes) ", + sourceName, decompFormatBytes(m.input.ByteSize)) + titleBar := decompTitleBarStyle.Width(m.width).Render(title) + sb.WriteString(titleBar) + sb.WriteString("\n") + + // 2x2 grid + panelW := (m.width - 1) / 2 + if panelW < 12 { + panelW = 12 + } + + topRow := m.renderPanelRow(0, 1, panelW) + sb.WriteString(topRow) + + bottomRow := m.renderPanelRow(2, 3, panelW) + sb.WriteString(bottomRow) + + // Metrics bar + metrics := m.renderMetrics() + metricsBar := metricsBarStyle.Width(m.width).Render(metrics) + sb.WriteString(metricsBar) + sb.WriteString("\n") + + // Help bar + help := "[r] Re-run [1-4] Focus tier [Tab] Next [q] Quit" + if m.running { + help = "Running... [q] Quit" + } + helpBar := helpBarStyle.Width(m.width).Render(help) + sb.WriteString(helpBar) + + return sb.String() +} + +func (m decompTUIModel) renderPanelRow(left, right, panelW int) string { + leftPanel := m.renderPanel(left, panelW) + rightPanel := m.renderPanel(right, panelW) + return lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", rightPanel) + "\n" +} + +func (m decompTUIModel) renderPanel(idx, totalW int) string { + style := inactiveBorderStyle + if idx == m.active { + style = activeBorderStyle.BorderForeground(tierColors[idx]) + } + + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(tierColors[idx]) + header := headerStyle.Render(tierNames[idx]) + + content := header + "\n" + m.viewports[idx].View() + + return style.Width(totalW - 2).Render(content) +} + +func (m decompTUIModel) renderMetrics() string { + if m.err != nil { + return fmt.Sprintf("Error: %v", m.err) + } + if m.running { + return "Running decomposition..." + } + if m.result == nil { + return "No results yet" + } + + r := m.result + parts := []string{ + fmt.Sprintf("Total: %s", formatLatency(r.Metrics.TotalLatencyMs)), + } + if r.Metrics.CompressionRatio > 0 { + parts = append(parts, fmt.Sprintf("%.0f:1 compression", r.Metrics.CompressionRatio)) + } + parts = append(parts, r.InputHash) + return strings.Join(parts, " | ") +} + +// === Entry Point === + +func runDecompWorkbench(input *DecompInput, runner *DecompositionRunner) error { + // Run initial decomposition before starting TUI + result, err := runner.Run(context.Background(), input) + if err != nil { + return fmt.Errorf("initial decomposition: %w", err) + } + + m := initialDecompTUIModel(input, result, runner) + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err = p.Run() + return err +} diff --git a/decompose_tui_test.go b/decompose_tui_test.go new file mode 100644 index 0000000..fdcc797 --- /dev/null +++ b/decompose_tui_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "testing" + "time" +) + +func TestDecompTUIModelInit(t *testing.T) { + input := &DecompInput{ + Text: "Test input for decomposition workbench.", + Format: "plaintext", + SourceURI: "file://test.md", + ByteSize: 39, + } + + result := &DecompositionResult{ + InputHash: "abc123def456", + InputSize: 39, + InputFormat: "plaintext", + SourceURI: "file://test.md", + Tier0: &Tier0Result{Summary: "A test summary."}, + Tier1: &Tier1Result{ + Summary: "A longer test summary with more detail.", + KeyTerms: []string{"test", "decomposition"}, + }, + Tier2: &Tier2Result{ + Title: "Test Document", + Type: "knowledge", + Tags: []string{"test"}, + Summary: "Test summary for tier 2.", + Sections: []Tier2Section{ + {Heading: "Overview", Content: "Some content."}, + }, + }, + Tier3Raw: "Test input for decomposition workbench.", + Metrics: DecompMetrics{ + Tier0Tokens: 4, + Tier0LatencyMs: 100, + Tier1Tokens: 10, + Tier1LatencyMs: 200, + Tier2Tokens: 25, + Tier2LatencyMs: 500, + TotalLatencyMs: 800, + CompressionRatio: 2.6, + }, + CreatedAt: time.Now(), + } + + // Runner can be nil for init test — we only check model state. + m := initialDecompTUIModel(input, result, nil) + + // Verify initial state + if m.active != 0 { + t.Errorf("expected active=0, got %d", m.active) + } + if m.running { + t.Error("expected running=false") + } + if m.err != nil { + t.Errorf("expected nil error, got %v", m.err) + } + if m.input != input { + t.Error("input not stored") + } + if m.result != result { + t.Error("result not stored") + } + + // Verify viewports were populated + t0Content := m.viewports[0].View() + if t0Content == "" { + t.Error("T0 viewport should have content after populate") + } + + // Init should return nil (no initial command) + cmd := m.Init() + if cmd != nil { + t.Error("Init() should return nil") + } +} + +func TestDecompTUIModelNilResult(t *testing.T) { + input := &DecompInput{ + Text: "Some text", + Format: "plaintext", + ByteSize: 9, + } + + m := initialDecompTUIModel(input, nil, nil) + + if m.result != nil { + t.Error("expected nil result") + } + if m.active != 0 { + t.Errorf("expected active=0, got %d", m.active) + } +} diff --git a/internal/engine/process.go b/internal/engine/process.go index 46e26e5..c5109e1 100644 --- a/internal/engine/process.go +++ b/internal/engine/process.go @@ -28,9 +28,32 @@ import ( "sync" "time" + "github.com/cogos-dev/cogos/trace" "github.com/google/uuid" ) +// TraceEmitter is the bus-publish hook for cycle-trace events. The main +// package sets this at startup; engine calls it best-effort (never blocks +// the metabolic cycle on a nil hook or a slow consumer). +var TraceEmitter func(ev trace.CycleEvent) + +// TraceIdentity returns the identity name stamped as the `source` field on +// emitted events. Set by the main package at startup; defaults to "cog". +var TraceIdentity = func() string { return "cog" } + +// emitTrace is a best-effort wrapper around TraceEmitter that swallows +// construction errors and never blocks the cycle. +func emitTrace(ev trace.CycleEvent, err error) { + if err != nil { + slog.Debug("process: trace event build failed", "err", err) + return + } + if TraceEmitter == nil { + return + } + TraceEmitter(ev) +} + // ProcessState represents the four operational states of the v3 process. type ProcessState int @@ -129,6 +152,16 @@ type Process struct { // lastIndexHEAD tracks the HEAD hash at last index rebuild, so we skip // rebuilding when nothing has changed. lastIndexHEAD string + + // currentCycleID correlates all cycle-trace events emitted during one + // iteration of the metabolic cycle (external event handling or a + // consolidation tick). Set fresh at the start of each iteration; + // protected by mu. + currentCycleID string + + // hookRegistry holds ADR-072 state-transition hooks loaded from + // .cog/hooks/transitions/*.yaml. May be nil if the directory is missing. + hookRegistry *stateHookRegistry } // NewProcess constructs and initialises the process. @@ -157,7 +190,16 @@ func NewProcess(cfg *Config, nucleus *Nucleus) *Process { lastMaintenanceTick: now, nodeHealth: NewNodeHealth(), nodeManifest: loadManifestQuiet(cfg), + hookRegistry: loadStateHookRegistry(workspaceRootFromCfg(cfg)), + } +} + +// workspaceRootFromCfg returns the workspace root from the config, or "" if nil. +func workspaceRootFromCfg(cfg *Config) string { + if cfg == nil { + return "" } + return cfg.WorkspaceRoot } // loadManifestQuiet loads the node manifest, returning nil if not found. @@ -269,6 +311,23 @@ func (p *Process) Send(evt *GateEvent) bool { } } +// SubmitExternal is the canonical entry point for external perturbations +// (dashboard chat, modality inlets, etc.) into the metabolic cycle. It is a +// non-blocking alias of Send(): the external channel has bounded capacity and +// the cycle must never stall on a slow or noisy producer. Callers that need +// backpressure should observe the returned bool and drop/log accordingly. +func (p *Process) SubmitExternal(evt *GateEvent) bool { + if p == nil || evt == nil { + return false + } + select { + case p.externalCh <- evt: + return true + default: + return false + } +} + // Run starts the continuous process loop. It blocks until ctx is cancelled. func (p *Process) Run(ctx context.Context) error { // Build CogDoc index first (fast — just frontmatter parsing). @@ -414,8 +473,9 @@ func (p *Process) handleTailerBlock(block CogBlock) { // handleExternal processes an external perturbation. func (p *Process) handleExternal(evt *GateEvent) { + p.beginCycle() result := p.gate.Process(evt) - p.transition(result.StateTransition) + p.transitionWithReason(result.StateTransition, fmt.Sprintf("external:%s", evt.Type)) slog.Debug("process: external event", "type", evt.Type, "state", p.State(), @@ -430,7 +490,8 @@ func (p *Process) handleExternal(evt *GateEvent) { // Loop 2: the TrajectoryModel is updated (prediction + error computation) // Loop 3: the model acts on the field (pre-warm, attenuate, coherence signal) func (p *Process) runConsolidation() { - p.transition(StateConsolidating) + p.beginCycle() + p.transitionWithReason(StateConsolidating, "consolidation.tick") // Record the current tick window before updating the maintenance watermark. now := time.Now() @@ -534,7 +595,7 @@ func (p *Process) runConsolidation() { "surprise": surprise, }) - p.transition(StateReceptive) + p.transitionWithReason(StateReceptive, "consolidation.complete") } // NodeHealth returns the current node health state (for use by the serve layer). @@ -572,7 +633,8 @@ func (p *Process) emitHeartbeat() { p.TrustState.CoherenceFingerprint = coherenceHash p.mu.Unlock() - p.transition(StateDormant) + p.beginCycle() + p.transitionWithReason(StateDormant, "heartbeat") state := p.State().String() fieldSize := p.field.Len() fingerprint := p.Fingerprint() @@ -663,13 +725,51 @@ func (p *Process) emitHeartbeat() { // transition moves the process to a new state (with logging). func (p *Process) transition(next ProcessState) { + p.transitionWithReason(next, "") +} + +// transitionWithReason is transition() with a human-readable reason string +// that is attached to the emitted cycle-trace event. Best-effort emission: +// a nil TraceEmitter or a build error never blocks the cycle. +func (p *Process) transitionWithReason(next ProcessState, reason string) { p.mu.Lock() prev := p.state p.state = next + cycleID := p.currentCycleID p.mu.Unlock() - if prev != next { - slog.Debug("process: state transition", "from", prev, "to", next) + if prev == next { + return } + slog.Debug("process: state transition", "from", prev, "to", next, "reason", reason) + if cycleID == "" { + // Fall back to a one-shot ID so events remain correlatable at least + // within this single transition; real iterations mint their own. + cycleID = uuid.NewString() + } + emitTrace(trace.NewStateTransition(TraceIdentity(), cycleID, prev, next, reason)) + // ADR-072: dispatch per-state enter handlers + declarative StateHooks on a + // goroutine so transition() never blocks the caller (may hold p.mu upstream). + go p.dispatchEnterHandler(prev, next) +} + +// beginCycle mints a new cycle ID for the start of a metabolic-cycle +// iteration (consolidation tick or external event). All trace events emitted +// during the iteration share this ID. Returns the new ID. +func (p *Process) beginCycle() string { + id := uuid.NewString() + p.mu.Lock() + p.currentCycleID = id + p.mu.Unlock() + return id +} + +// CurrentCycleID returns the current iteration's cycle ID (may be empty +// between iterations). Exported so the agent harness and tool dispatch paths +// can correlate their own trace events with the running kernel cycle. +func (p *Process) CurrentCycleID() string { + p.mu.RLock() + defer p.mu.RUnlock() + return p.currentCycleID } // emitEvent records a ledger event for the process session. diff --git a/internal/engine/transition_hooks.go b/internal/engine/transition_hooks.go new file mode 100644 index 0000000..276428f --- /dev/null +++ b/internal/engine/transition_hooks.go @@ -0,0 +1,330 @@ +// transition_hooks.go — ADR-072 state-transition hook dispatch. +// +// Implements the per-transition handler layer described in ADR-072 +// ("State-Transition Hooks for Node Lifecycle"). On every state change, +// `transition()` invokes a per-state enter handler. Each handler: +// +// - Runs the minimum-viable per-ADR work inline (small, bounded). +// - Dispatches any matching declarative StateHook definitions loaded from +// `.cog/hooks/transitions/*.yaml`. Only the `shell:` form is implemented +// at this revision — agent-form hooks (ADR-072 Phase 3) are loaded and +// matched but logged as pending instead of executed. +// +// Non-goals of this file: +// - Event-bus emission of transition records (owned by a sibling track). +// - Full agent-subprocess spawning with budget/timeout (ADR-072 Phase 3). +// - Condition evaluation beyond the scaffold (ADR-072 Phase 4). +// +// Concurrency: all handler bodies and hook executions run on goroutines +// spawned by `transition()`. They must not take `p.mu`; they may read +// immutable fields (cfg, sessionID) directly. +package engine + +import ( + "context" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "gopkg.in/yaml.v3" +) + +// stateHook is the minimal in-memory representation of a StateHook YAML +// definition. It covers both shell-form and agent-form hooks; only shell +// execution is wired up at this revision. +type stateHook struct { + Name string + From ProcessState + To ProcessState + Priority int + Async bool + Timeout time.Duration + Shell string // inline shell command (shell-form) + HasAgent bool // agent-form — loaded but not executed yet +} + +// stateHookFile is the YAML wire format; see `.cog/hooks/transitions/*.yaml`. +type stateHookFile struct { + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + } `yaml:"metadata"` + Spec struct { + Trigger struct { + From string `yaml:"from"` + To string `yaml:"to"` + } `yaml:"trigger"` + Priority int `yaml:"priority"` + Async bool `yaml:"async"` + Timeout string `yaml:"timeout"` + Shell string `yaml:"shell"` + Agent *struct { + Model string `yaml:"model"` + } `yaml:"agent"` + } `yaml:"spec"` +} + +// stateHookRegistry holds the sorted list of hooks matching each (from,to) pair. +type stateHookRegistry struct { + mu sync.RWMutex + hooks []stateHook +} + +// loadStateHookRegistry loads StateHook YAML files from the given workspace. +// Missing directory is a non-error (no hooks). Malformed files are skipped +// with a warning; they must not block kernel startup. +func loadStateHookRegistry(workspaceRoot string) *stateHookRegistry { + reg := &stateHookRegistry{} + if workspaceRoot == "" { + return reg + } + dir := filepath.Join(workspaceRoot, ".cog", "hooks", "transitions") + entries, err := os.ReadDir(dir) + if err != nil { + if !os.IsNotExist(err) { + slog.Debug("transition_hooks: registry dir read failed", "dir", dir, "err", err) + } + return reg + } + var loaded []stateHook + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { + continue + } + path := filepath.Join(dir, e.Name()) + raw, err := os.ReadFile(path) + if err != nil { + slog.Warn("transition_hooks: read failed", "file", e.Name(), "err", err) + continue + } + var f stateHookFile + if err := yaml.Unmarshal(raw, &f); err != nil { + slog.Warn("transition_hooks: parse failed", "file", e.Name(), "err", err) + continue + } + if f.Kind != "StateHook" { + continue + } + from, okF := parseProcessState(f.Spec.Trigger.From) + to, okT := parseProcessState(f.Spec.Trigger.To) + if !okF || !okT { + slog.Warn("transition_hooks: unknown trigger states", "file", e.Name(), "from", f.Spec.Trigger.From, "to", f.Spec.Trigger.To) + continue + } + to_ := stateHook{ + Name: f.Metadata.Name, + From: from, + To: to, + Priority: f.Spec.Priority, + Async: f.Spec.Async, + Timeout: parseHookTimeout(f.Spec.Timeout), + Shell: strings.TrimSpace(f.Spec.Shell), + HasAgent: f.Spec.Agent != nil, + } + loaded = append(loaded, to_) + } + sort.SliceStable(loaded, func(i, j int) bool { return loaded[i].Priority < loaded[j].Priority }) + reg.hooks = loaded + if len(loaded) > 0 { + slog.Info("transition_hooks: registry loaded", "count", len(loaded), "dir", dir) + } + return reg +} + +func parseProcessState(s string) (ProcessState, bool) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "active": + return StateActive, true + case "receptive": + return StateReceptive, true + case "consolidating": + return StateConsolidating, true + case "dormant": + return StateDormant, true + } + return 0, false +} + +func parseHookTimeout(s string) time.Duration { + s = strings.TrimSpace(s) + if s == "" { + return 30 * time.Second + } + if d, err := time.ParseDuration(s); err == nil { + return d + } + // Accept bare integer (seconds), as ADR-072 example uses `timeout: 300`. + if d, err := time.ParseDuration(s + "s"); err == nil { + return d + } + return 30 * time.Second +} + +// match returns the hooks registered for the given transition, in priority order. +func (r *stateHookRegistry) match(from, to ProcessState) []stateHook { + r.mu.RLock() + defer r.mu.RUnlock() + var out []stateHook + for _, h := range r.hooks { + if h.From == from && h.To == to { + out = append(out, h) + } + } + return out +} + +// runTransitionHooks executes all matching hooks for a (from,to) transition. +// Always invoked in a goroutine from `transition()`. Honors each hook's +// timeout; agent-form hooks are recognized but not executed at this revision +// (see ADR-072 Phase 3). +func (p *Process) runTransitionHooks(from, to ProcessState) { + if p.hookRegistry == nil { + return + } + hooks := p.hookRegistry.match(from, to) + if len(hooks) == 0 { + return + } + workspace := "" + sessionID := "" + if p.cfg != nil { + workspace = p.cfg.WorkspaceRoot + } + sessionID = p.sessionID + for _, h := range hooks { + switch { + case h.Shell != "": + p.execShellHook(h, from, to, workspace, sessionID) + case h.HasAgent: + // TODO: implement agent-subprocess spawn with budget+timeout per + // ADR-072 Phase 3. Until then, the declaration is honored only as + // telemetry so operators can see that a hook was registered. + slog.Info("transition_hooks: agent hook pending (ADR-072 Phase 3)", + "name", h.Name, "from", from, "to", to) + default: + slog.Debug("transition_hooks: hook has no executable body", "name", h.Name) + } + } +} + +// execShellHook runs a `shell:` StateHook under a timeout. Environment +// variables match the contract documented in `.cog/hooks/transitions/` YAML +// files (COG_TRANSITION_FROM / _TO / COG_SESSION_ID / COG_WORKSPACE). +func (p *Process) execShellHook(h stateHook, from, to ProcessState, workspace, sessionID string) { + timeout := h.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "sh", "-c", h.Shell) + cmd.Env = append(os.Environ(), + "COG_TRANSITION_FROM="+from.String(), + "COG_TRANSITION_TO="+to.String(), + "COG_SESSION_ID="+sessionID, + "COG_WORKSPACE="+workspace, + ) + if workspace != "" { + cmd.Dir = workspace + } + out, err := cmd.CombinedOutput() + if err != nil { + slog.Warn("transition_hooks: shell hook failed", + "name", h.Name, "err", err, "output", strings.TrimSpace(string(out))) + return + } + slog.Debug("transition_hooks: shell hook ok", "name", h.Name, "from", from, "to", to) +} + +// ── Per-state enter handlers (ADR-072 §"Standard Hook Points") ────────────── +// +// Each handler runs off the main select loop on a goroutine spawned by +// transition(). Handlers must be short (<~20 lines), non-blocking w.r.t. +// the kernel tick cadence, and must not hold p.mu. +// +// The inline work done here is deliberately minimal. The bulk of per-state +// behavior lives either (a) in the declarative StateHook YAML registry +// dispatched above, or (b) in existing package logic that is not being +// moved in this revision (runConsolidation remains ticker-driven until the +// registry replaces it; see ADR-072 §"Migration Path" step 7). + +// enterActive fires on any → active. The kernel is processing an external +// perturbation. Per ADR-072 "dormant → active: identity card reload, field +// warm-up, session init". At this revision the field is kept warm by the +// consolidation tick (runConsolidation calls p.field.Update), so no +// additional inline work is required — matching hooks in the registry +// (e.g., a future `identity-reload.yaml`) are dispatched by the caller. +func (p *Process) enterActive(from ProcessState) { + // intentionally minimal per ADR-072 §Standard Hook Points (row: dormant→active) + // TODO: migrate identity-card reload here once the agent-hook executor lands + // (ADR-072 Phase 3). Until then, any declarative hook file in + // .cog/hooks/transitions/ with trigger from=* to=active fires via + // runTransitionHooks. + _ = from +} + +// enterReceptive fires on any → receptive. This is the idle/alert baseline +// state. Per ADR-072 "consolidating → receptive: light cone pruning, index +// rebuild, ResourcePressure update". Index rebuild is already performed by +// runConsolidation before it transitions to Receptive, so the inline body +// here stays empty to avoid duplicate work; matching registry hooks still +// fire. +func (p *Process) enterReceptive(from ProcessState) { + // intentionally no-op per ADR-072 §"Migration Path" step 1: + // existing ticker logic remains authoritative until hooks replace it. + _ = from +} + +// enterConsolidating fires on any → consolidating. Per ADR-072 +// "active → consolidating: session review, journal write, turn metric +// aggregation". The heavyweight consolidation work (field update, index +// rebuild, coherence check, observer cycle, consolidation CogDoc write) +// is already performed by runConsolidation() which is invoked on the +// consolidationTicker path; duplicating it here would double the work on +// ticker-driven transitions. Instead the declarative hooks +// (session-review.yaml, log-transition.yaml) carry the per-session +// journaling, dispatched via runTransitionHooks. +func (p *Process) enterConsolidating(from ProcessState) { + // intentionally minimal per ADR-072 §"Migration Path" step 7 — hook + // dispatch is the new trigger surface; runConsolidation() still owns + // the in-kernel pass until its logic is migrated into hook definitions. + _ = from +} + +// enterDormant fires on any → dormant. Per ADR-072 "receptive → dormant: +// deep consolidation, embedding refresh, sleep-time compute". At this +// revision the only inline work is recording the transition timestamp as +// the last consolidation watermark when a full consolidation has not run +// recently, so the next heartbeat's consolidation gate triggers correctly. +// Expensive resource-release work (model refs, embedding reloads) is +// delegated to declarative hooks. +func (p *Process) enterDormant(from ProcessState) { + // No-op inline body: heartbeat path already handles dormant cadence + // and consolidation gating (see emitHeartbeat). Matching registry + // hooks (future on-dormant.yaml) handle deep-sleep work. + // TODO: once ADR-072 Phase 3 lands, move expensive resource-release + // calls into a declarative agent hook rather than inline Go. + _ = from +} + +// dispatchEnterHandler routes to the correct enter handler for the target +// state. Called from transition() on a goroutine; runs both the inline +// handler and the declarative-hook dispatch for (from,to). +func (p *Process) dispatchEnterHandler(from, to ProcessState) { + switch to { + case StateActive: + p.enterActive(from) + case StateReceptive: + p.enterReceptive(from) + case StateConsolidating: + p.enterConsolidating(from) + case StateDormant: + p.enterDormant(from) + } + p.runTransitionHooks(from, to) +} diff --git a/internal/engine/web/dashboard.html b/internal/engine/web/dashboard.html index a2c46fa..a2b9bac 100644 --- a/internal/engine/web/dashboard.html +++ b/internal/engine/web/dashboard.html @@ -332,6 +332,19 @@ } .cogdoc-viewer-overlay.open { display: block; } +/* ── Decompose Panel ── */ +.decomp-row { + padding: 6px 8px; border-radius: 4px; margin-bottom: 4px; + background: var(--bg-elevated); border: 1px solid var(--border); + font-family: var(--font-mono); font-size: 11px; line-height: 1.5; +} +.decomp-row .decomp-hash { color: var(--accent); } +.decomp-row .decomp-tier { color: var(--text-dim); } +.decomp-row .decomp-total { color: var(--text); font-weight: 600; } +.decomp-row.ratio-high { border-left: 3px solid var(--green); } +.decomp-row.ratio-mid { border-left: 3px solid var(--yellow); } +.decomp-row.ratio-low { border-left: 3px solid var(--text-dim); opacity: 0.7; } + /* ── Proprioceptive Panel ── */ .proprio-section { margin-bottom: 20px; @@ -446,6 +459,7 @@

CogOS v3

-- -- field: -- + agent: -- @@ -466,6 +480,8 @@

CogOS v3

+ +
@@ -580,6 +596,101 @@

CogOS v3

+ +
+
+
Recent Decompositions
+
+ No decompositions recorded yet +
+
+
+ +
+ +
+
+ Agent Status + +
+
+
+ Model:-- + Uptime:-- + Interval:-- + Cycles:-- +
+
+
+ -- + u=-- +
+
--
+
--
+
+
+
+ + +
+
System Activity
+
+
+ User:-- + Claude Code:-- + Event delta:-- + Hottest:-- +
+
+
+ + +
+
Urgency History
+ +
+ + +
+
Rolling Memory (Tier 0)
+
+ No memory entries yet +
+
+ + +
+
Pending Proposals
+
+ No pending proposals +
+
+ + +
+
Link Feed
+
+
+ Last pull:-- + Next:-- + Raw:-- + Enriched:-- + Failed:-- + Total:-- +
+
+
+
+
+ + +
+
Cycle Traces
+
+ No traces yet +
+
+
@@ -1355,10 +1466,349 @@

CogOS v3

}, 10000); } +// ── Decomposition Panel ── +let decompRefreshTimer = null; + +async function refreshDecompositions() { + try { + const res = await fetch(API + '/v1/bus/events?bus_id=decompose&type=decompose.complete&limit=10'); + if (!res.ok) return; + const data = await res.json(); + renderDecompositions(data.events || data || []); + } catch {} +} + +function fmtLatency(ms) { + if (ms == null) return '--'; + if (ms < 1000) return ms + 'ms'; + return (ms / 1000).toFixed(1) + 's'; +} + +function renderDecompositions(events) { + const container = document.getElementById('decompose-history'); + if (!events || events.length === 0) { + container.innerHTML = '
No decompositions recorded yet
'; + return; + } + + let html = ''; + for (const evt of events) { + const p = evt.payload || evt; + const hash = (p.input_hash || '--').substring(0, 10); + const ratio = p.compression_ratio || 0; + const ratioClass = ratio > 100 ? 'ratio-high' : (ratio >= 50 ? 'ratio-mid' : 'ratio-low'); + const ts = evt.ts ? new Date(evt.ts).toLocaleTimeString() : '--'; + + html += '
' + + '[' + ts + '] ' + + '' + hash + '...' + + '
' + + 'T0: ' + (p.tier0_tokens || 0) + 'tok ' + fmtLatency(p.tier0_latency_ms) + '' + + ' | T1: ' + (p.tier1_tokens || 0) + 'tok ' + fmtLatency(p.tier1_latency_ms) + '' + + ' | T2: ' + (p.tier2_tokens || 0) + 'tok ' + fmtLatency(p.tier2_latency_ms) + '' + + '
' + + 'Total: ' + fmtLatency(p.total_latency) + '' + + (ratio > 0 ? ' | ' + Math.round(ratio) + ':1' : '') + + (p.schema_conformant === false ? ' | retry' : '') + + '
'; + } + container.innerHTML = html; +} + +// Hook tab switching to trigger decompose/agent refresh +document.querySelectorAll('.panel-tab').forEach(tab => { + tab.addEventListener('click', () => { + if (tab.dataset.tab === 'decompose') refreshDecompositions(); + if (tab.dataset.tab === 'agent') refreshAgent(); + }); +}); + +function startDecompPolling() { + if (decompRefreshTimer) return; + decompRefreshTimer = setInterval(() => { + const tab = document.querySelector('.panel-tab[data-tab="decompose"]'); + if (tab && tab.classList.contains('active')) { + refreshDecompositions(); + } + }, 15000); +} + +// ── Agent Panel ── +const actionColors = { + sleep: '#4ade80', observe: '#facc15', consolidate: '#60a5fa', + propose: '#c084fc', repair: '#f97316', escalate: '#f87171', + skip: '#6b7280', error: '#ef4444' +}; + +let agentRefreshTimer = null; +let urgencyHistory = []; +let lastTraceHash = ''; + +async function refreshAgent() { + try { + const res = await fetch(API + '/v1/agent/status'); + if (!res.ok) return; + const d = await res.json(); + renderAgentPill(d); + renderAgentStatus(d); + renderAgentActivity(d.activity); + renderAgentMemory(d.memory); + renderAgentProposals(d.proposals); + renderAgentInbox(d.inbox); + if (d.last_urgency != null && d.cycle_count > 0) { + urgencyHistory.push(d.last_urgency); + if (urgencyHistory.length > 20) urgencyHistory.shift(); + renderUrgencySparkline(); + } + // Fetch traces when Agent tab is active + const agentTab = document.querySelector('.panel-tab[data-tab="agent"]'); + if (agentTab && agentTab.classList.contains('active')) { + refreshAgentTraces(); + } + } catch {} +} + +async function refreshAgentTraces() { + try { + const res = await fetch(API + '/v1/agent/traces'); + if (!res.ok) return; + const traces = await res.json(); + // Only re-render if data changed — avoids collapsing open
+ const hash = JSON.stringify(traces.map(t => t.cycle + ':' + t.action)); + if (hash === lastTraceHash) return; + lastTraceHash = hash; + renderAgentTraces(traces); + } catch {} +} + +function renderAgentTraces(traces) { + const container = document.getElementById('agent-traces'); + if (!traces || traces.length === 0) { + container.innerHTML = '
No traces yet
'; + return; + } + // Show most recent first + let html = ''; + for (let i = traces.length - 1; i >= 0; i--) { + const t = traces[i]; + const color = actionColors[t.action] || '#888'; + const ago = t.timestamp ? timeSince(new Date(t.timestamp)) : '--'; + const hasResult = t.result && t.result.length > 0; + const id = `trace-${t.cycle}`; + html += `
`; + html += ``; + html += `${t.action}`; + html += ` u=${t.urgency.toFixed(1)} · ${ago} ago · ${t.duration_ms}ms`; + if (hasResult) html += ` ▸ has result`; + html += ``; + html += `
`; + html += `
Reason: ${esc(t.reason)}
`; + if (t.target) html += `
Target: ${esc(t.target)}
`; + if (hasResult) { + html += `
Execute Result:
`; + html += `
${esc(t.result)}
`; + } + html += `
Observation (${t.observation.length} chars)`; + html += `
${esc(t.observation)}
`; + html += `
`; + html += `
`; + } + container.innerHTML = html; +} + +function renderAgentPill(d) { + const pill = document.getElementById('pill-agent'); + if (!d.alive) { + pill.textContent = 'agent: --'; + pill.className = 'pill warn'; + return; + } + const action = d.last_action || '--'; + const color = actionColors[action] || '#888'; + if (d.last_cycle) { + const ago = timeSince(new Date(d.last_cycle)); + pill.textContent = `▲ ${d.cycle_count} · ${ago}`; + pill.className = 'pill ok'; + // Warn if stale (>2x interval) + const intervalSec = parseInterval(d.interval); + const ageSec = (Date.now() - new Date(d.last_cycle).getTime()) / 1000; + if (ageSec > intervalSec * 2.5) pill.className = 'pill warn'; + } else { + pill.textContent = `▲ ${d.cycle_count}`; + pill.className = 'pill ok'; + } +} + +function renderAgentStatus(d) { + document.getElementById('agent-model').textContent = d.model || '--'; + document.getElementById('agent-uptime').textContent = d.uptime || '--'; + document.getElementById('agent-interval').textContent = d.interval || '--'; + document.getElementById('agent-cycles').textContent = d.cycle_count || '0'; + + const actionEl = document.getElementById('agent-last-action'); + const action = d.last_action || '--'; + actionEl.textContent = action; + actionEl.style.color = actionColors[action] || 'var(--text)'; + + document.getElementById('agent-last-urgency').textContent = `u=${(d.last_urgency || 0).toFixed(1)}`; + document.getElementById('agent-last-reason').textContent = d.last_reason || '--'; + + if (d.last_cycle) { + const ago = timeSince(new Date(d.last_cycle)); + document.getElementById('agent-last-time').textContent = `${ago} ago · ${d.last_duration_ms}ms`; + } +} + +function renderAgentActivity(a) { + if (!a) return; + const userEl = document.getElementById('activity-user'); + userEl.textContent = `${a.user_presence} (${a.user_last_event_ago || '--'} ago)`; + userEl.style.color = a.user_presence === 'active' ? '#4ade80' : a.user_presence === 'recent' ? '#facc15' : 'var(--text-dim)'; + + const ccEl = document.getElementById('activity-cc'); + ccEl.textContent = a.claude_code_active > 0 ? `${a.claude_code_active} (${a.claude_code_events} events)` : 'none'; + + document.getElementById('activity-delta').textContent = `${a.total_event_delta} events`; + document.getElementById('activity-hottest').textContent = a.hottest_bus ? `${a.hottest_bus} (${a.hottest_delta})` : '--'; +} + +function renderAgentMemory(mem) { + const container = document.getElementById('agent-memory'); + if (!mem || mem.length === 0) { + container.innerHTML = '
No memory entries yet
'; + return; + } + let html = ''; + for (const entry of mem) { + const color = actionColors[entry.action] || '#888'; + html += `
`; + html += `${entry.action}`; + html += ` u=${entry.urgency.toFixed(1)} · ${entry.ago} ago`; + html += `
${esc(entry.sentence)}
`; + html += '
'; + } + container.innerHTML = html; +} + +function renderAgentProposals(proposals) { + const container = document.getElementById('agent-proposals'); + if (!proposals || proposals.length === 0) { + container.innerHTML = '
No pending proposals
'; + return; + } + let html = ''; + for (const p of proposals) { + html += `
`; + html += `[${esc(p.type)}] `; + html += `${esc(p.title)}`; + html += `
urgency: ${p.urgency} · ${p.created || '--'}
`; + html += '
'; + } + container.innerHTML = html; +} + +function renderAgentInbox(inbox) { + if (!inbox) return; + document.getElementById('inbox-last-pull').textContent = inbox.last_pull_ago ? `${inbox.last_pull_ago} ago` : 'never'; + const nextEl = document.getElementById('inbox-next-pull'); + nextEl.textContent = inbox.next_pull_in || '--'; + nextEl.style.color = inbox.next_pull_in === 'overdue' ? 'var(--red)' : 'var(--text)'; + document.getElementById('inbox-raw').textContent = inbox.raw_count || '0'; + document.getElementById('inbox-enriched').textContent = inbox.enriched_count || '0'; + document.getElementById('inbox-failed').textContent = inbox.failed_count || '0'; + document.getElementById('inbox-total').textContent = inbox.total_count || '0'; + + const recentEl = document.getElementById('inbox-recent'); + if (inbox.recent_enrichments && inbox.recent_enrichments.length > 0) { + let html = '
Recent Enrichments
'; + for (const e of inbox.recent_enrichments) { + const connColor = e.connections > 0 ? 'var(--green)' : 'var(--text-dim)'; + html += `
`; + html += `${esc(e.title)}`; + html += ` ${e.connections} conn`; + html += ` ${e.ago} ago`; + html += '
'; + } + recentEl.innerHTML = html; + } else { + recentEl.innerHTML = ''; + } +} + +function renderUrgencySparkline() { + const svg = document.getElementById('agent-sparkline'); + if (!svg || urgencyHistory.length < 2) return; + const w = svg.clientWidth || 300; + const h = 40; + const padding = 2; + const points = urgencyHistory.map((u, i) => { + const x = padding + (i / (urgencyHistory.length - 1)) * (w - padding * 2); + const y = h - padding - (u * (h - padding * 2)); + return `${x},${y}`; + }); + // Color gradient based on latest urgency + const latest = urgencyHistory[urgencyHistory.length - 1]; + const color = latest > 0.7 ? '#f87171' : latest > 0.3 ? '#facc15' : '#4ade80'; + svg.innerHTML = ` + + + ${urgencyHistory.map((u, i) => { + const x = padding + (i / (urgencyHistory.length - 1)) * (w - padding * 2); + const y = h - padding - (u * (h - padding * 2)); + return ``; + }).join('')} + `; +} + +function timeSince(date) { + const sec = Math.floor((Date.now() - date.getTime()) / 1000); + if (sec < 60) return sec + 's'; + if (sec < 3600) return Math.floor(sec / 60) + 'm'; + if (sec < 86400) return Math.floor(sec / 3600) + 'h ' + (Math.floor(sec / 60) % 60) + 'm'; + return Math.floor(sec / 86400) + 'd'; +} + +function parseInterval(s) { + if (!s) return 300; + const m = s.match(/(\d+)m/); + if (m) return parseInt(m[1]) * 60; + const h = s.match(/(\d+)h/); + if (h) return parseInt(h[1]) * 3600; + return 300; +} + +async function triggerAgentCycle() { + const btn = document.getElementById('agent-trigger-btn'); + btn.textContent = '⏳ Running...'; + btn.disabled = true; + try { + await fetch(API + '/v1/agent/trigger', { method: 'POST' }); + // Wait a moment for the cycle to complete, then refresh + setTimeout(() => { + refreshAgent(); + btn.textContent = '▶ Trigger Cycle'; + btn.disabled = false; + }, 12000); + } catch { + btn.textContent = '▶ Trigger Cycle'; + btn.disabled = false; + } +} + +function startAgentPolling() { + if (agentRefreshTimer) return; + refreshAgent(); // immediate first fetch + agentRefreshTimer = setInterval(() => { + refreshAgent(); // always poll for pill, but full render only when tab active + }, 5000); +} + // ── Init ── pollHealth(); setInterval(pollHealth, 5000); startProprioPolling(); +startDecompPolling(); +startAgentPolling(); inputEl.focus(); diff --git a/internal/linkfeed/linkfeed.go b/internal/linkfeed/linkfeed.go new file mode 100644 index 0000000..5618c74 --- /dev/null +++ b/internal/linkfeed/linkfeed.go @@ -0,0 +1,969 @@ +// Package linkfeed implements Discord link-feed ingestion + enrichment. +// +// Layer 1: pullDiscordLinkFeed() — scheduled Discord pull with pagination, +// deduplication, and CogDoc creation. +// Layer 2: enrichLink() — lightweight URL fetch + decomposition + cross-ref. +// +// Both are registered as agent tools (pull_link_feed, enrich_link) via +// RegisterLinkFeedTools, and the helpers (ScanInbox, LinkFeedLastPull, +// BuildInboxSummaryForAPI) are called directly from the observation +// pipeline for inbox awareness. +// +// Extracted from apps/cogos/agent_linkfeed.go as wave 1a of ADR-085. +// The file retains its original logic; only the package boundary and the +// Harness interface (to decouple from *main.AgentHarness) are new. +package linkfeed + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "golang.org/x/net/html" + "gopkg.in/yaml.v3" +) + +// LinkFeedChannelID is the Discord channel for #link-feed. +const LinkFeedChannelID = "1366246672275083375" + +// LinkFeedCheckInterval is how often the agent should pull new links. +const LinkFeedCheckInterval = 2 * time.Hour + +// InboxLinksRelPath is the memory-relative path to the inbox. +const InboxLinksRelPath = ".cog/mem/semantic/inbox/links" + +// linkFeedStateRelPath is the state file for tracking last_message_id. +const linkFeedStateRelPath = ".cog/mem/semantic/research/link-feed-state.json" + +// discordAuthRelPath is the Discord auth config. +const discordAuthRelPath = ".cog/config/discord/auth.yaml" + +// --- Harness coupling --- + +// Harness is the minimal surface linkfeed needs from the agent harness. +// The main package adapts its *AgentHarness to this interface. Using an +// interface (rather than importing the concrete type) keeps linkfeed a +// leaf package: main depends on linkfeed, not vice versa. +// +// RegisterTool takes flat arguments (not ToolDefinition/ToolFunc structs) +// so main's adapter can translate into its own types without linkfeed +// needing to agree on a shared struct layout. +type Harness interface { + // RegisterTool registers a callable tool with the agent's tool loop. + RegisterTool(name, description string, parameters json.RawMessage, + fn func(ctx context.Context, args json.RawMessage) (json.RawMessage, error)) + // GenerateJSON sends a prompt to the model in JSON mode and returns + // the raw JSON content. + GenerateJSON(ctx context.Context, systemPrompt, userPrompt string) (string, error) +} + +// toolFn is the signature for a registered tool function. +type toolFn func(ctx context.Context, args json.RawMessage) (json.RawMessage, error) + +// --- Types --- + +// LinkFeedItem represents a single link extracted from a Discord message. +type LinkFeedItem struct { + URL string `json:"url"` + Title string `json:"title"` + File string `json:"file"` + MessageID string `json:"message_id"` + Author string `json:"author"` +} + +// linkFeedState is the persisted state for the link feed puller. +type linkFeedState struct { + LastMessageID string `json:"last_message_id"` + LastUpdate string `json:"last_update"` + ChannelID string `json:"channel_id"` + GuildID string `json:"guild_id"` + TotalLinks int `json:"total_links"` +} + +// discordAuth holds the bot token from auth.yaml. +type discordAuth struct { + Token string `yaml:"token"` +} + +// discordMessage is a minimal Discord message for JSON unmarshalling. +type discordMessage struct { + ID string `json:"id"` + Content string `json:"content"` + Timestamp string `json:"timestamp"` + Author struct { + Username string `json:"username"` + } `json:"author"` + Embeds []struct { + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + } `json:"embeds"` +} + +// --- Tool Registration --- + +// RegisterLinkFeedTools adds link feed tools to the agent harness. +func RegisterLinkFeedTools(h Harness, workspaceRoot string) { + h.RegisterTool( + "pull_link_feed", + "Pull new links from the Discord #link-feed channel. Reads config from disk, fetches new messages since last pull, extracts URLs, deduplicates, and writes raw CogDocs to the inbox.", + json.RawMessage(`{ + "type": "object", + "properties": {}, + "required": [] + }`), + newPullLinkFeedFunc(workspaceRoot), + ) + h.RegisterTool( + "enrich_link", + "Enrich a raw link CogDoc in the inbox. Fetches URL content, extracts metadata, runs Tier 0+1 decomposition, searches for cross-references, and updates the CogDoc status to enriched.", + json.RawMessage(`{ + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "Filename of the raw CogDoc in inbox/links/ to enrich" + } + }, + "required": ["filename"] + }`), + newEnrichLinkFunc(h, workspaceRoot), + ) + h.RegisterTool( + "list_inbox", + "List raw (unenriched) inbox files. Returns filenames you can pass to enrich_link. Use this to find items to enrich.", + json.RawMessage(`{ + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "Max files to return (default 10)"} + }, + "required": [] + }`), + newListInboxFunc(workspaceRoot), + ) +} + +// --- list_inbox tool --- + +func newListInboxFunc(root string) toolFn { + return func(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var p struct { + Limit int `json:"limit"` + } + if err := json.Unmarshal(args, &p); err != nil { + p.Limit = 10 + } + if p.Limit <= 0 { + p.Limit = 10 + } + if p.Limit > 50 { + p.Limit = 50 + } + + // Read inbox directory directly for the requested limit + dir := filepath.Join(root, InboxLinksRelPath) + entries, err := os.ReadDir(dir) + if err != nil { + return json.Marshal(map[string]string{"error": "cannot read inbox: " + err.Error()}) + } + + var rawFiles []string + var rawCount, enrichedCount int + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".cog.md") { + continue + } + path := filepath.Join(dir, e.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + status := extractFMField(string(data), "status") + switch status { + case "enriched": + enrichedCount++ + case "raw", "": + rawCount++ + if len(rawFiles) < p.Limit { + rawFiles = append(rawFiles, e.Name()) + } + } + } + + return json.Marshal(map[string]interface{}{ + "raw_count": rawCount, + "enriched_count": enrichedCount, + "files": rawFiles, + "showing": len(rawFiles), + }) + } +} + +// --- pull_link_feed tool --- + +func newPullLinkFeedFunc(root string) toolFn { + return func(ctx context.Context, _ json.RawMessage) (json.RawMessage, error) { + items, err := pullDiscordLinkFeed(ctx, root) + if err != nil { + return json.Marshal(map[string]interface{}{ + "error": err.Error(), + }) + } + return json.Marshal(map[string]interface{}{ + "new_links": len(items), + "items": items, + }) + } +} + +// --- enrich_link tool --- + +func newEnrichLinkFunc(h Harness, root string) toolFn { + return func(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var p struct { + Filename string `json:"filename"` + } + if err := json.Unmarshal(args, &p); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if p.Filename == "" { + return json.Marshal(map[string]string{"error": "filename is required"}) + } + result, err := enrichLink(ctx, h, root, p.Filename) + if err != nil { + return json.Marshal(map[string]interface{}{ + "error": err.Error(), + "filename": p.Filename, + }) + } + return json.Marshal(result) + } +} + +// --- Core: pullDiscordLinkFeed --- + +// pullDiscordLinkFeed fetches new messages from Discord, extracts URLs, +// deduplicates, and writes raw CogDocs. Returns the list of new items. +func pullDiscordLinkFeed(ctx context.Context, root string) ([]LinkFeedItem, error) { + // 1. Read auth + auth, err := ReadDiscordAuth(root) + if err != nil { + return nil, fmt.Errorf("discord auth: %w", err) + } + + // 2. Read state + state, err := readLinkFeedState(root) + if err != nil { + // Fresh start — no state file yet + state = &linkFeedState{ + ChannelID: LinkFeedChannelID, + } + } + + // 3. Build existing URL set for dedup + existingURLs, err := buildExistingURLSet(root) + if err != nil { + log.Printf("[linkfeed] warning: could not read inbox for dedup: %v", err) + existingURLs = make(map[string]bool) + } + + // 4. Fetch messages with pagination + var allMessages []discordMessage + afterID := state.LastMessageID + for { + messages, err := fetchDiscordMessages(ctx, auth.Token, LinkFeedChannelID, afterID) + if err != nil { + return nil, fmt.Errorf("discord API: %w", err) + } + if len(messages) == 0 { + break + } + allMessages = append(allMessages, messages...) + // Discord returns oldest first when using "after", so last item is newest + afterID = messages[len(messages)-1].ID + // If we got fewer than 100, no more pages + if len(messages) < 100 { + break + } + } + + if len(allMessages) == 0 { + return nil, nil + } + + // 5. Extract URLs and write CogDocs + inboxDir := filepath.Join(root, InboxLinksRelPath) + if err := os.MkdirAll(inboxDir, 0o755); err != nil { + return nil, fmt.Errorf("create inbox dir: %w", err) + } + + var items []LinkFeedItem + var newestID string + urlRegex := regexp.MustCompile(`https?://[^\s<>]+`) + + for _, msg := range allMessages { + // Track newest message ID + if newestID == "" || msg.ID > newestID { + newestID = msg.ID + } + + // Extract URLs: embeds first, then regex on content + var urls []string + seen := make(map[string]bool) + + for _, embed := range msg.Embeds { + if embed.URL != "" && !seen[embed.URL] { + urls = append(urls, embed.URL) + seen[embed.URL] = true + } + } + for _, u := range urlRegex.FindAllString(msg.Content, -1) { + // Clean trailing punctuation + u = strings.TrimRight(u, ".,;:!?)") + if !seen[u] { + urls = append(urls, u) + seen[u] = true + } + } + + for _, rawURL := range urls { + // Deduplicate against existing inbox + if existingURLs[rawURL] { + continue + } + existingURLs[rawURL] = true + + // Determine title from embed or domain + title := urlDomain(rawURL) + for _, embed := range msg.Embeds { + if embed.URL == rawURL && embed.Title != "" { + title = embed.Title + break + } + } + + // Build filename + ts := parseDiscordTimestamp(msg.Timestamp) + slug := sanitizeFilename(title) + if len(slug) > 60 { + slug = slug[:60] + } + filename := fmt.Sprintf("discord-%s-%s.cog.md", ts.Format("2006-01-02"), slug) + + // Build CogDoc content + cogdoc := buildRawLinkCogDoc(rawURL, title, msg.Author.Username, msg.ID, msg.Timestamp, ts) + + path := filepath.Join(inboxDir, filename) + if err := os.WriteFile(path, []byte(cogdoc), 0o644); err != nil { + log.Printf("[linkfeed] failed to write %s: %v", filename, err) + continue + } + + items = append(items, LinkFeedItem{ + URL: rawURL, + Title: title, + File: filename, + MessageID: msg.ID, + Author: msg.Author.Username, + }) + } + } + + // 6. Update state + if newestID != "" { + state.LastMessageID = newestID + state.LastUpdate = time.Now().Format("2006-01-02") + state.TotalLinks += len(items) + if err := writeLinkFeedState(root, state); err != nil { + log.Printf("[linkfeed] warning: failed to update state: %v", err) + } + } + + log.Printf("[linkfeed] pulled %d new links from %d messages", len(items), len(allMessages)) + return items, nil +} + +// --- Core: enrichLink --- + +// enrichResult is the structured output from enrichment. +type enrichResult struct { + Filename string `json:"filename"` + URL string `json:"url"` + Status string `json:"status"` + Title string `json:"title"` + Summary string `json:"summary,omitempty"` + ContentType string `json:"content_type,omitempty"` + KeyTerms []string `json:"key_terms,omitempty"` + Refs []string `json:"refs,omitempty"` + Connections int `json:"connections"` +} + +// enrichLink fetches, decomposes, and cross-references a raw inbox link. +func enrichLink(ctx context.Context, h Harness, root, filename string) (*enrichResult, error) { + // 30-second timeout for the entire enrichment + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + path := filepath.Join(root, InboxLinksRelPath, filepath.Base(filename)) + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read cogdoc: %w", err) + } + + contentStr := string(content) + + // Extract URL from frontmatter + linkURL := extractFMField(contentStr, "url") + if linkURL == "" { + return nil, fmt.Errorf("no url field in %s", filename) + } + + result := &enrichResult{ + Filename: filename, + URL: linkURL, + Status: "enriched", + } + + // Fetch URL content (lightweight — just meta tags) + fetchedTitle, fetchedDesc := fetchURLMeta(ctx, linkURL) + if fetchedTitle != "" { + result.Title = fetchedTitle + } else { + result.Title = extractFMField(contentStr, "title") + } + + // Build text for decomposition + decompInput := fmt.Sprintf("Title: %s\nURL: %s\n", result.Title, linkURL) + if fetchedDesc != "" { + decompInput += "Description: " + fetchedDesc + "\n" + } + + // Run Tier 0 + Tier 1 decomposition via the harness + var summary string + var keyTerms []string + var contentType string + + decompCtx, decompCancel := context.WithTimeout(ctx, 15*time.Second) + defer decompCancel() + + tier1Prompt := `Analyze this link and respond as JSON: +{"summary": "one sentence summary", "key_terms": ["term1", "term2"], "content_type": "article|paper|repo|video|tool|discussion"} + +Content types: article (blog/news), paper (academic), repo (GitHub/code), video (YouTube/media), tool (software/service), discussion (forum/thread)` + + tier1JSON, err := h.GenerateJSON(decompCtx, tier1Prompt, decompInput) + if err == nil { + var t1 struct { + Summary string `json:"summary"` + KeyTerms []string `json:"key_terms"` + ContentType string `json:"content_type"` + } + if json.Unmarshal([]byte(tier1JSON), &t1) == nil { + summary = t1.Summary + keyTerms = t1.KeyTerms + contentType = t1.ContentType + } + } + + result.Summary = summary + result.KeyTerms = keyTerms + result.ContentType = contentType + + // Search workspace memory for cross-references + var refs []string + if len(keyTerms) > 0 { + searchQuery := strings.Join(keyTerms, " ") + searchOut, err := runCogCommand(ctx, root, "memory", "search", searchQuery) + if err == nil { + var searchResult struct { + Output string `json:"output"` + } + if json.Unmarshal(searchOut, &searchResult) == nil { + // Parse search results — each line is a path + for _, line := range strings.Split(searchResult.Output, "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.Contains(line, "inbox/links/") { + refs = append(refs, line) + if len(refs) >= 3 { + break + } + } + } + } + } + } + result.Refs = refs + result.Connections = len(refs) + + // Update the CogDoc + updatedDoc := updateCogDocEnriched(contentStr, result) + if err := os.WriteFile(path, []byte(updatedDoc), 0o644); err != nil { + return nil, fmt.Errorf("write enriched cogdoc: %w", err) + } + + return result, nil +} + +// --- Inbox scanning --- + +// InboxSummary holds counts for the inbox awareness observation. +type InboxSummary struct { + RawCount int `json:"raw_count"` + EnrichedCount int `json:"enriched_count"` + FailedCount int `json:"failed_count"` + TotalCount int `json:"total_count"` + NewestRaw []string `json:"newest_raw,omitempty"` +} + +// ScanInbox reads the inbox directory and returns a summary. +func ScanInbox(root string) *InboxSummary { + dir := filepath.Join(root, InboxLinksRelPath) + entries, err := os.ReadDir(dir) + if err != nil { + return &InboxSummary{} + } + + summary := &InboxSummary{} + type fileInfo struct { + name string + modTime time.Time + } + var rawFiles []fileInfo + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".cog.md") { + continue + } + summary.TotalCount++ + + // Quick frontmatter status check + path := filepath.Join(dir, e.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + status := extractFMField(string(data), "status") + switch status { + case "raw": + summary.RawCount++ + info, _ := e.Info() + mt := time.Time{} + if info != nil { + mt = info.ModTime() + } + rawFiles = append(rawFiles, fileInfo{name: e.Name(), modTime: mt}) + case "enriched": + summary.EnrichedCount++ + case "fetch_failed": + summary.FailedCount++ + default: + // Treat unknown status as raw + summary.RawCount++ + } + } + + // Get newest 5 raw files + // Sort by mod time descending (simple insertion sort for small N) + for i := 1; i < len(rawFiles); i++ { + for j := i; j > 0 && rawFiles[j].modTime.After(rawFiles[j-1].modTime); j-- { + rawFiles[j], rawFiles[j-1] = rawFiles[j-1], rawFiles[j] + } + } + for i := 0; i < len(rawFiles) && i < 5; i++ { + summary.NewestRaw = append(summary.NewestRaw, rawFiles[i].name) + } + + return summary +} + +// LinkFeedLastPull returns how long ago the last pull happened. +func LinkFeedLastPull(root string) (time.Duration, error) { + state, err := readLinkFeedState(root) + if err != nil { + return 0, err + } + if state.LastUpdate == "" { + return 0, fmt.Errorf("no last_update in state") + } + t, err := time.Parse("2006-01-02", state.LastUpdate) + if err != nil { + return 0, err + } + return time.Since(t), nil +} + +// --- Helpers --- + +// ReadDiscordAuth reads the bot token from .cog/config/discord/auth.yaml. +// Exported so the observation pipeline can probe "configured?" status. +func ReadDiscordAuth(root string) (*discordAuth, error) { + path := filepath.Join(root, discordAuthRelPath) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", discordAuthRelPath, err) + } + var auth discordAuth + if err := yaml.Unmarshal(data, &auth); err != nil { + return nil, fmt.Errorf("parse auth.yaml: %w", err) + } + if auth.Token == "" { + return nil, fmt.Errorf("empty token in auth.yaml") + } + return &auth, nil +} + +func readLinkFeedState(root string) (*linkFeedState, error) { + path := filepath.Join(root, linkFeedStateRelPath) + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var state linkFeedState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + return &state, nil +} + +func writeLinkFeedState(root string, state *linkFeedState) error { + path := filepath.Join(root, linkFeedStateRelPath) + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func buildExistingURLSet(root string) (map[string]bool, error) { + dir := filepath.Join(root, InboxLinksRelPath) + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + urls := make(map[string]bool, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".cog.md") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + u := extractFMField(string(data), "url") + if u != "" { + urls[u] = true + } + } + return urls, nil +} + +func fetchDiscordMessages(ctx context.Context, token, channelID, afterID string) ([]discordMessage, error) { + apiURL := fmt.Sprintf("https://discord.com/api/v10/channels/%s/messages?limit=100", channelID) + if afterID != "" { + apiURL += "&after=" + afterID + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bot "+token) + req.Header.Set("User-Agent", "CogOS-LinkFeed/1.0") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("discord API %d: %s", resp.StatusCode, string(body)) + } + + var messages []discordMessage + if err := json.Unmarshal(body, &messages); err != nil { + return nil, fmt.Errorf("parse messages: %w", err) + } + + return messages, nil +} + +func urlDomain(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + return u.Hostname() +} + +func parseDiscordTimestamp(ts string) time.Time { + // Discord timestamps: "2026-04-15T12:34:56.789000+00:00" + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + // Try alternate format + t, err = time.Parse("2006-01-02T15:04:05.999999+00:00", ts) + if err != nil { + return time.Now() + } + } + return t +} + +func buildRawLinkCogDoc(rawURL, title, author, messageID, timestamp string, ts time.Time) string { + domain := urlDomain(rawURL) + now := time.Now().Format(time.RFC3339) + sourceID := fmt.Sprintf("discord-%s", messageID) + + var sb strings.Builder + sb.WriteString("---\n") + sb.WriteString(fmt.Sprintf("title: %q\n", title)) + sb.WriteString("type: link\n") + sb.WriteString("source: discord\n") + sb.WriteString(fmt.Sprintf("url: %q\n", rawURL)) + sb.WriteString(fmt.Sprintf("domain: %q\n", domain)) + sb.WriteString("status: raw\n") + sb.WriteString(fmt.Sprintf("source_id: %q\n", sourceID)) + sb.WriteString(fmt.Sprintf("discord_author: %q\n", author)) + sb.WriteString(fmt.Sprintf("created: %q\n", timestamp)) + sb.WriteString(fmt.Sprintf("ingested: %q\n", now)) + sb.WriteString("memory_sector: semantic\n") + sb.WriteString("tags: [link-feed, raw]\n") + sb.WriteString("---\n\n") + sb.WriteString(fmt.Sprintf("# %s\n\n", title)) + sb.WriteString(fmt.Sprintf("URL: %s\n", rawURL)) + sb.WriteString(fmt.Sprintf("Source: Discord #link-feed by %s\n", author)) + sb.WriteString(fmt.Sprintf("Posted: %s\n", ts.Format("2006-01-02 15:04 MST"))) + + return sb.String() +} + +// fetchURLMeta does a lightweight HTTP GET and extracts title + description +// from HTML meta tags. 10-second timeout, no full page download. +func fetchURLMeta(ctx context.Context, rawURL string) (title, description string) { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "", "" + } + req.Header.Set("User-Agent", "CogOS-LinkFeed/1.0 (metadata only)") + + resp, err := client.Do(req) + if err != nil { + return "", "" + } + defer resp.Body.Close() + + // Read at most 64KB — enough for meta tags in the + limited := io.LimitReader(resp.Body, 64*1024) + doc, err := html.Parse(limited) + if err != nil { + return "", "" + } + + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode { + if n.Data == "title" && n.FirstChild != nil && title == "" { + title = n.FirstChild.Data + } + if n.Data == "meta" { + var name, content string + for _, a := range n.Attr { + switch strings.ToLower(a.Key) { + case "name", "property": + name = strings.ToLower(a.Val) + case "content": + content = a.Val + } + } + if (name == "description" || name == "og:description") && description == "" { + description = content + } + if name == "og:title" && title == "" { + title = content + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(doc) + return title, description +} + +// updateCogDocEnriched updates a raw CogDoc with enrichment data. +func updateCogDocEnriched(content string, r *enrichResult) string { + // Update status + content = strings.Replace(content, "status: raw", "status: enriched", 1) + + // Update title if we have a better one + if r.Title != "" { + oldTitle := extractFMField(content, "title") + if oldTitle != "" && r.Title != oldTitle { + content = strings.Replace(content, fmt.Sprintf("title: %q", oldTitle), fmt.Sprintf("title: %q", r.Title), 1) + } + } + + // Add content_type if not present + if r.ContentType != "" && !strings.Contains(content, "content_type:") { + content = strings.Replace(content, "status: enriched", fmt.Sprintf("content_type: %s\nstatus: enriched", r.ContentType), 1) + } + + // Add key terms to tags + if len(r.KeyTerms) > 0 { + oldTags := "tags: [link-feed, raw]" + terms := make([]string, 0, len(r.KeyTerms)+2) + terms = append(terms, "link-feed", "enriched") + terms = append(terms, r.KeyTerms...) + newTags := fmt.Sprintf("tags: [%s]", strings.Join(terms, ", ")) + content = strings.Replace(content, oldTags, newTags, 1) + } + + // Append enrichment section after the existing content + var sb strings.Builder + sb.WriteString(content) + if r.Summary != "" { + sb.WriteString(fmt.Sprintf("\n## Summary\n%s\n", r.Summary)) + } + if len(r.Refs) > 0 { + sb.WriteString("\n## Cross-References\n") + for _, ref := range r.Refs { + sb.WriteString(fmt.Sprintf("- %s\n", ref)) + } + } + + return sb.String() +} + +// --- Agent Inbox Summary for Status API --- + +// AgentInboxSummary is the enrichment data for the status API. +type AgentInboxSummary struct { + RawCount int `json:"raw_count"` + EnrichedCount int `json:"enriched_count"` + FailedCount int `json:"failed_count"` + TotalCount int `json:"total_count"` + LastPull string `json:"last_pull,omitempty"` + LastPullAgo string `json:"last_pull_ago,omitempty"` + NextPullIn string `json:"next_pull_in,omitempty"` + RecentEnrichments []RecentEnrichmentItem `json:"recent_enrichments,omitempty"` +} + +// RecentEnrichmentItem is a single recent enrichment for the dashboard. +type RecentEnrichmentItem struct { + Title string `json:"title"` + Connections int `json:"connections"` + Ago string `json:"ago"` +} + +// BuildInboxSummaryForAPI constructs the inbox summary for the status endpoint. +func BuildInboxSummaryForAPI(root string) *AgentInboxSummary { + inbox := ScanInbox(root) + summary := &AgentInboxSummary{ + RawCount: inbox.RawCount, + EnrichedCount: inbox.EnrichedCount, + FailedCount: inbox.FailedCount, + TotalCount: inbox.TotalCount, + } + + // Last pull timing + ago, err := LinkFeedLastPull(root) + if err == nil { + summary.LastPullAgo = formatAgo(ago) + remaining := LinkFeedCheckInterval - ago + if remaining > 0 { + summary.NextPullIn = formatAgo(remaining) + } else { + summary.NextPullIn = "overdue" + } + } else { + summary.LastPullAgo = "never" + summary.NextPullIn = "overdue" + } + + state, err := readLinkFeedState(root) + if err == nil { + summary.LastPull = state.LastUpdate + } + + return summary +} + +// --- Private helpers duplicated from main (kept local to break cycle) --- + +// extractFMField pulls a simple field value from YAML frontmatter. +// Duplicated from main's agent_tools_propose.go to keep linkfeed a leaf +// package. The original stays in main for its many other callers. +func extractFMField(content, field string) string { + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, field+":") { + val := strings.TrimSpace(strings.TrimPrefix(line, field+":")) + val = strings.Trim(val, `"`) + return val + } + } + return "" +} + +// sanitizeFilename makes a string safe for use as a filename. +// Duplicated from main's agent_tools_propose.go for the same reason as +// extractFMField. +func sanitizeFilename(s string) string { + s = strings.ToLower(s) + s = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + if r == ' ' { + return '-' + } + return -1 + }, s) + if len(s) > 50 { + s = s[:50] + } + return s +} + +// runCogCommand executes ./scripts/cog with the given arguments and returns +// the output as JSON. Duplicated from main's agent_tools.go. +func runCogCommand(ctx context.Context, root string, args ...string) (json.RawMessage, error) { + cogScript := filepath.Join(root, "scripts", "cog") + cmd := exec.CommandContext(ctx, cogScript, args...) + cmd.Dir = root + + out, err := cmd.CombinedOutput() + if err != nil { + return json.Marshal(map[string]string{ + "error": err.Error(), + "output": string(out), + }) + } + return json.Marshal(map[string]string{"output": string(out)}) +} + +// formatAgo produces a human-readable duration like "5m" or "2h". +// Duplicated from main's agent_decompose.go. +func formatAgo(d time.Duration) string { + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) +} diff --git a/component_provider.go b/internal/providers/component/component_provider.go similarity index 56% rename from component_provider.go rename to internal/providers/component/component_provider.go index bd3c760..d89340c 100644 --- a/component_provider.go +++ b/internal/providers/component/component_provider.go @@ -1,12 +1,19 @@ -// component_provider.go — Reconciliation provider for workspace components. +// Package component provides the Reconcilable provider for workspace +// components. +// // Part of ADR-060 Phase 1: report-only drift detection. // -// Implements Reconcilable to detect drift between the declared component -// registry (.cog/conf/components.cog.md) and live on-disk state (indexed blobs). +// The provider detects drift between the declared component registry +// (.cog/conf/components.cog.md) and live on-disk state (indexed blobs). // Phase 1 is report-only — ApplyPlan prints what it would do but does not // execute destructive actions. - -package main +// +// Extracted from apps/cogos root in Wave 1a of ADR-085 (subpackage +// decomposition). The component registry/indexer types and helpers still +// live in the main package; this package reaches them through the DI +// function variables declared below. The main package wires them in an +// init() hook (see apps/cogos/component_wiring.go). +package component import ( "context" @@ -14,15 +21,73 @@ import ( "os" "path/filepath" "sort" + + "github.com/cogos-dev/cogos/internal/workspace" + "github.com/cogos-dev/cogos/pkg/reconcile" +) + +// --- Dependency-injection seams ---------------------------------------------- +// +// These variables are set by the main package in an init() hook so the +// provider can reach the component registry / indexer machinery that still +// lives at the apps/cogos root. If any are nil at call time the provider +// returns an explanatory error rather than panicking. + +// RegistryDecl describes a single declared component. Mirrors the fields +// component_provider.go reads from the main-package ComponentDecl type. +type RegistryDecl struct { + Kind string + Required bool +} + +// ReconcilerConfig mirrors the reconciler sub-config the provider reads. +type ReconcilerConfig struct { + PruneUnregistered bool +} + +// Registry is the structural view of the component registry the provider +// needs. The concrete main-package ComponentRegistry is converted into this +// shape by LoadRegistry. +type Registry struct { + Reconciler ReconcilerConfig + Components map[string]RegistryDecl +} + +// Blob is the structural view of a single indexed component blob. +type Blob struct { + Kind string + TreeHash string + CommitHash string + BlobHash string + Dirty bool + Language string + BuildSystem string + IndexedAt string +} + +var ( + // LoadRegistry loads and returns the declared component registry. + LoadRegistry func(root string) (*Registry, error) + // IndexComponentPaths runs the indexer and returns the list of known + // component paths (keys of the Merkle index). + IndexComponentPaths func(root string) ([]string, error) + // LoadBlob loads a single indexed blob for the given component path. + LoadBlob func(root, path string) (*Blob, error) + // EncodePath produces the stable reconcile address fragment for a + // component path. + EncodePath func(path string) string + // NowISO returns the current timestamp in ISO-8601 form. + NowISO func() string ) -// ComponentProvider implements Reconcilable for workspace component management. +// ComponentProvider implements reconcile.Reconcilable for workspace +// component management. type ComponentProvider struct { root string } func init() { - RegisterProvider("component", &ComponentProvider{}) + reconcile.RegisterProvider("component", &ComponentProvider{}) } func (c *ComponentProvider) Type() string { return "component" } @@ -30,27 +95,31 @@ func (c *ComponentProvider) Type() string { return "component" } // LoadConfig loads the component registry from .cog/conf/components.cog.md. func (c *ComponentProvider) LoadConfig(root string) (any, error) { c.root = root - return loadComponentRegistry(root) + if LoadRegistry == nil { + return nil, fmt.Errorf("component provider: LoadRegistry dependency not wired") + } + return LoadRegistry(root) } -// FetchLive runs the component indexer and loads individual blobs for comparison. -// Returns map[string]*ComponentBlob keyed by component path. +// FetchLive runs the component indexer and loads individual blobs for +// comparison. Returns map[string]*Blob keyed by component path. func (c *ComponentProvider) FetchLive(ctx context.Context, config any) (any, error) { root := c.root if root == "" { return nil, fmt.Errorf("component provider: root not set (call LoadConfig first)") } + if IndexComponentPaths == nil || LoadBlob == nil { + return nil, fmt.Errorf("component provider: indexer dependencies not wired") + } - // Run the indexer to refresh all blobs and the Merkle index. - idx, err := indexComponents(root) + paths, err := IndexComponentPaths(root) if err != nil { return nil, fmt.Errorf("component index: %w", err) } - // Load each blob for detailed comparison. - blobs := make(map[string]*ComponentBlob, len(idx.Components)) - for path := range idx.Components { - blob, err := loadComponentBlob(root, path) + blobs := make(map[string]*Blob, len(paths)) + for _, path := range paths { + blob, err := LoadBlob(root, path) if err != nil { // Non-fatal: log and skip this component. fmt.Fprintf(os.Stderr, "warning: could not load blob for %s: %v\n", path, err) @@ -64,11 +133,11 @@ func (c *ComponentProvider) FetchLive(ctx context.Context, config any) (any, err // ComputePlan compares declared config (registry) against live state (blobs) // and produces a reconciliation plan. -func (c *ComponentProvider) ComputePlan(config any, live any, state *ReconcileState) (*ReconcilePlan, error) { - reg := config.(*ComponentRegistry) - blobs := live.(map[string]*ComponentBlob) +func (c *ComponentProvider) ComputePlan(config any, live any, state *reconcile.State) (*reconcile.Plan, error) { + reg := config.(*Registry) + blobs := live.(map[string]*Blob) - plan := &ReconcilePlan{ + plan := &reconcile.Plan{ ResourceType: "component", GeneratedAt: nowISO(), ConfigPath: ".cog/conf/components.cog.md", @@ -91,8 +160,8 @@ func (c *ComponentProvider) ComputePlan(config any, live any, state *ReconcileSt blob, exists := blobs[path] if !exists { // Declared but not on disk — no blob was produced. - action := ReconcileAction{ - Action: ActionCreate, + action := reconcile.Action{ + Action: reconcile.ActionCreate, ResourceType: "component", Name: path, Details: map[string]any{ @@ -123,7 +192,7 @@ func (c *ComponentProvider) ComputePlan(config any, live any, state *ReconcileSt // Check tree hash against previous state (if we have one). if state != nil { - stateIdx := ReconcileResourceIndex(state) + stateIdx := reconcile.ResourceIndex(state) addr := "component." + encodePath(path) if prev, ok := stateIdx[addr]; ok { if prev.ExternalID != "" && blob.TreeHash != prev.ExternalID { @@ -141,8 +210,8 @@ func (c *ComponentProvider) ComputePlan(config any, live any, state *ReconcileSt } if drifted { - action := ReconcileAction{ - Action: ActionUpdate, + action := reconcile.Action{ + Action: reconcile.ActionUpdate, ResourceType: "component", Name: path, Details: map[string]any{ @@ -154,8 +223,8 @@ func (c *ComponentProvider) ComputePlan(config any, live any, state *ReconcileSt plan.Actions = append(plan.Actions, action) plan.Summary.Updates++ } else { - action := ReconcileAction{ - Action: ActionSkip, + action := reconcile.Action{ + Action: reconcile.ActionSkip, ResourceType: "component", Name: path, Details: map[string]any{ @@ -180,8 +249,8 @@ func (c *ComponentProvider) ComputePlan(config any, live any, state *ReconcileSt for _, path := range undeclaredPaths { // Add as warnings — prune_unregistered defaults to false. if reg.Reconciler.PruneUnregistered { - action := ReconcileAction{ - Action: ActionDelete, + action := reconcile.Action{ + Action: reconcile.ActionDelete, ResourceType: "component", Name: path, Details: map[string]any{ @@ -201,19 +270,19 @@ func (c *ComponentProvider) ComputePlan(config any, live any, state *ReconcileSt // ApplyPlan is Phase 1: report-only. Prints what would be done but does not // execute any destructive actions. -func (c *ComponentProvider) ApplyPlan(ctx context.Context, plan *ReconcilePlan) ([]ReconcileResult, error) { - var results []ReconcileResult +func (c *ComponentProvider) ApplyPlan(ctx context.Context, plan *reconcile.Plan) ([]reconcile.Result, error) { + var results []reconcile.Result for _, action := range plan.Actions { - if action.Action == ActionSkip { + if action.Action == reconcile.ActionSkip { continue } reason, _ := action.Details["reason"].(string) fmt.Printf(" [dry-run] %s %s: %s\n", action.Action, action.Name, reason) - results = append(results, ReconcileResult{ + results = append(results, reconcile.Result{ Phase: "component", Action: string(action.Action), Name: action.Name, - Status: ApplySkipped, + Status: reconcile.ApplySkipped, Error: "phase 1: report-only mode", }) } @@ -221,10 +290,10 @@ func (c *ComponentProvider) ApplyPlan(ctx context.Context, plan *ReconcilePlan) } // BuildState constructs reconcile state from live blobs. -func (c *ComponentProvider) BuildState(config any, live any, existing *ReconcileState) (*ReconcileState, error) { - blobs := live.(map[string]*ComponentBlob) +func (c *ComponentProvider) BuildState(config any, live any, existing *reconcile.State) (*reconcile.State, error) { + blobs := live.(map[string]*Blob) - state := &ReconcileState{ + state := &reconcile.State{ Version: 1, ResourceType: "component", GeneratedAt: nowISO(), @@ -238,10 +307,10 @@ func (c *ComponentProvider) BuildState(config any, live any, existing *Reconcile } for path, blob := range blobs { - resource := ReconcileResource{ + resource := reconcile.Resource{ Address: "component." + encodePath(path), Type: blob.Kind, - Mode: ModeManaged, + Mode: reconcile.ModeManaged, ExternalID: blob.TreeHash, Name: path, LastRefreshed: blob.IndexedAt, @@ -265,23 +334,30 @@ func (c *ComponentProvider) BuildState(config any, live any, existing *Reconcile } // Health returns the current three-axis status of the component subsystem. -func (c *ComponentProvider) Health() ResourceStatus { +func (c *ComponentProvider) Health() reconcile.ResourceStatus { root := c.root if root == "" { var err error - root, _, err = ResolveWorkspace() + root, _, err = workspace.ResolveWorkspace() if err != nil { - return ResourceStatus{ - Sync: SyncStatusUnknown, Health: HealthMissing, Operation: OperationIdle, + return reconcile.ResourceStatus{ + Sync: reconcile.SyncStatusUnknown, Health: reconcile.HealthMissing, Operation: reconcile.OperationIdle, Message: "workspace not found", } } } - reg, err := loadComponentRegistry(root) + if LoadRegistry == nil { + return reconcile.ResourceStatus{ + Sync: reconcile.SyncStatusUnknown, Health: reconcile.HealthDegraded, Operation: reconcile.OperationIdle, + Message: "component registry loader not wired", + } + } + + reg, err := LoadRegistry(root) if err != nil { - return ResourceStatus{ - Sync: SyncStatusUnknown, Health: HealthDegraded, Operation: OperationIdle, + return reconcile.ResourceStatus{ + Sync: reconcile.SyncStatusUnknown, Health: reconcile.HealthDegraded, Operation: reconcile.OperationIdle, Message: "component registry not found", } } @@ -298,17 +374,35 @@ func (c *ComponentProvider) Health() ResourceStatus { } if missing > 0 { - return ResourceStatus{ - Sync: SyncStatusOutOfSync, Health: HealthDegraded, Operation: OperationIdle, + return reconcile.ResourceStatus{ + Sync: reconcile.SyncStatusOutOfSync, Health: reconcile.HealthDegraded, Operation: reconcile.OperationIdle, Message: fmt.Sprintf("%d required components missing", missing), } } - return NewResourceStatus(SyncStatusSynced, HealthHealthy) + return reconcile.NewResourceStatus(reconcile.SyncStatusSynced, reconcile.HealthHealthy) } // --- helpers ---------------------------------------------------------------- +// nowISO returns the current timestamp, delegating to the main-package +// NowISO if it is wired and falling back to a sentinel otherwise. +func nowISO() string { + if NowISO != nil { + return NowISO() + } + return "" +} + +// encodePath delegates to the main-package EncodePath if wired; otherwise +// returns the path unchanged (safe fallback for the trivial cases). +func encodePath(path string) string { + if EncodePath != nil { + return EncodePath(path) + } + return path +} + // truncHash shortens a hash for display, keeping the first 12 characters. func truncHash(h string) string { if len(h) > 12 { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go new file mode 100644 index 0000000..7012c5c --- /dev/null +++ b/internal/workspace/workspace.go @@ -0,0 +1,160 @@ +// Package workspace resolves the active CogOS workspace root. +// +// Resolution precedence: +// 1. COG_ROOT environment variable (explicit) +// 2. COG_WORKSPACE env var (lookup in global config) +// 3. Local git repo detection (if inside a workspace) +// 4. current-workspace from ~/.cog/config +// +// This package is dependency-injected: the main package wires global-config +// loading and git-root detection at init time. This avoids pulling the full +// config/git machinery into this package while still allowing providers to +// import it cleanly. +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "sync" +) + +// ConfigProvider is the minimal view of the global config that +// ResolveWorkspace needs. The main package adapts its *GlobalConfig to this +// interface in an init() hook. +type ConfigProvider interface { + // CurrentWorkspace returns the name of the globally-selected workspace, + // or "" if none is set. + CurrentWorkspace() string + // WorkspacePath returns the filesystem path for the workspace with the + // given name, and a bool indicating whether it exists in the config. + WorkspacePath(name string) (string, bool) +} + +// Dependency injection points. The main package must set these in an init() +// hook before ResolveWorkspace is called. If either is nil, the corresponding +// resolution tier is skipped gracefully. +var ( + // LoadConfig loads the global config (~/.cog/config). + LoadConfig func() (ConfigProvider, error) + // GitRoot returns the path of the enclosing git repository. + GitRoot func() (string, error) +) + +// workspaceCache caches the result of workspace resolution. +// ResolveWorkspace() is called 25+ times per process; caching avoids +// redundant config reads and git operations. +var workspaceCache struct { + once sync.Once + root string + source string + err error +} + +// ResolveWorkspace determines the workspace root based on precedence: +// 1. COG_ROOT environment variable (explicit) +// 2. COG_WORKSPACE env var (lookup in global config) +// 3. Local git repo detection (if inside a workspace) +// 4. current-workspace from ~/.cog/config +// +// Returns (workspaceRoot, source, error) where source describes how it was +// resolved. +// +// Results are cached for the lifetime of the process since resolution is +// deterministic within a single invocation. +func ResolveWorkspace() (string, string, error) { + workspaceCache.once.Do(func() { + workspaceCache.root, workspaceCache.source, workspaceCache.err = resolveWorkspaceUncached() + }) + return workspaceCache.root, workspaceCache.source, workspaceCache.err +} + +// IsRealWorkspace checks if dir has a .cog/ directory that looks like a real +// CogOS workspace (has config/ or mem/), not just a bare .cog/ with .state/ only +// (which submodules sometimes have). +func IsRealWorkspace(dir string) bool { + cogDir := filepath.Join(dir, ".cog") + info, err := os.Stat(cogDir) + if err != nil || !info.IsDir() { + return false + } + // A real workspace has config/ subdirectory (not just mem/ or .state/) + if info, err := os.Stat(filepath.Join(cogDir, "config")); err == nil && info.IsDir() { + return true + } + return false +} + +// resolveWorkspaceUncached implements the actual workspace resolution logic. +// Called once per process via ResolveWorkspace(). +func resolveWorkspaceUncached() (string, string, error) { + // 1. Explicit COG_ROOT (set by wrapper for --root flag) + if root := os.Getenv("COG_ROOT"); root != "" { + cogDir := filepath.Join(root, ".cog") + if info, err := os.Stat(cogDir); err == nil && info.IsDir() { + return root, "explicit", nil + } + return "", "", fmt.Errorf("COG_ROOT=%s is not a valid workspace (no .cog/ directory)", root) + } + + // 2. COG_WORKSPACE env var (lookup by name in global config) + // Graceful degradation: fall through to tier 3 if config fails or workspace not found + if wsName := os.Getenv("COG_WORKSPACE"); wsName != "" && LoadConfig != nil { + config, err := LoadConfig() + if err == nil { // Only proceed if config loaded successfully + if path, ok := config.WorkspacePath(wsName); ok { + cogDir := filepath.Join(path, ".cog") + if _, err := os.Stat(cogDir); err != nil { + return "", "", fmt.Errorf("workspace '%s' at %s is invalid (no .cog/ directory)", wsName, path) + } + return path, "env", nil + } + } + // Fall through to tier 3 (local git detection) silently + } + + // 3. Local git detection (if inside a workspace) + // Walk up through git roots — a submodule might have a bare .cog/ directory + // (with only .state/) but the real workspace is the parent repo with config/ and mem/. + if GitRoot != nil { + if root, err := GitRoot(); err == nil { + if IsRealWorkspace(root) { + return root, "local", nil + } + // If git root has a .cog/ but it's not a real workspace (e.g. submodule), + // check the parent directory's git root (the superproject). + parentDir := filepath.Dir(root) + if parentDir != root { // not filesystem root + // Walk up looking for a real workspace + for dir := parentDir; dir != "/" && dir != "."; dir = filepath.Dir(dir) { + if IsRealWorkspace(dir) { + return dir, "local", nil + } + } + } + } + } + + // 4. Fall back to global current-workspace + if LoadConfig == nil { + return "", "", fmt.Errorf("no workspace found (run 'cog workspace add' or cd into a workspace)") + } + config, err := LoadConfig() + if err != nil { + return "", "", fmt.Errorf("failed to load global config: %w", err) + } + + if name := config.CurrentWorkspace(); name != "" { + if path, ok := config.WorkspacePath(name); ok { + cogDir := filepath.Join(path, ".cog") + if _, err := os.Stat(cogDir); err != nil { + return "", "", fmt.Errorf("workspace '%s' at %s is invalid (no .cog/ directory)", name, path) + } + return path, "global", nil + } + // Current workspace is set but doesn't exist in config + return "", "", fmt.Errorf("current workspace '%s' not found in config", name) + } + + return "", "", fmt.Errorf("no workspace found (run 'cog workspace add' or cd into a workspace)") +} diff --git a/local_runner.go b/local_runner.go new file mode 100644 index 0000000..fca3720 --- /dev/null +++ b/local_runner.go @@ -0,0 +1,551 @@ +// local_runner.go — Bare-metal process supervisor for services with spec.local. +// +// The ServiceProvider delegates to this runner when a CRD declares a local +// execution block and the container runtime is unavailable (or no image is +// declared). Each supervised process is tracked via a PID file under +// .cog/run/services/.pid; stdout/stderr are appended to .log. +// +// Supervision model: there are no watcher goroutines. The reconcile loop +// re-checks liveness on each cycle (default 5 min) and re-creates crashed +// processes. This keeps the kernel simple and inherits the same cadence as +// every other managed resource. + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// LocalProcess captures the live state of a supervised bare-metal service. +// +// Cmd and Args record the *resolved* argv that LocalStart actually invoked. +// They exist so LocalStop can verify the live process at PID still matches +// what we started before sending signals — without that check, PID reuse +// after a crash or reboot can land an unrelated user process at this PID +// and we'd kill it (and its whole process group, since we use Setsid). +type LocalProcess struct { + Name string `json:"name"` + PID int `json:"pid"` + StartedAt string `json:"started_at"` + CmdHash string `json:"cmd_hash"` + Workdir string `json:"workdir"` + LogPath string `json:"log_path"` + Cmd string `json:"cmd,omitempty"` // resolved executable path (post-venv lookup) + Args []string `json:"args,omitempty"` // argv after Cmd + Running bool `json:"running"` // populated by LocalStatus, not persisted +} + +// localServicesDir returns the directory holding PID/log files for local services. +func localServicesDir(root string) string { + return filepath.Join(root, ".cog", "run", "services") +} + +func localPIDPath(root, name string) string { + return filepath.Join(localServicesDir(root), name+".pid") +} + +func localLogPath(root, name string) string { + return filepath.Join(localServicesDir(root), name+".log") +} + +// ─── Command construction ─────────────────────────────────────────────────────── + +// localCmdHash produces a stable hash of the command + args + workdir + venv +// + env so we can detect drift between declared spec and a running process +// without comparing argv verbatim (which is noisy across shell escaping +// variations). +// +// Venv is included because the same Command (e.g. "python3") resolves to a +// different interpreter under a different venv — without it, swapping the +// venv field leaves reconcile reporting "in sync" while running the wrong +// binary. +func localCmdHash(local *ServiceLocal, workdir string) string { + h := sha256.New() + h.Write([]byte(local.Command)) + h.Write([]byte{0}) + for _, a := range local.Args { + h.Write([]byte(a)) + h.Write([]byte{0}) + } + h.Write([]byte(workdir)) + h.Write([]byte{0}) + // Venv influences which binary `Command` resolves to (see LocalStart's + // venv lookup); changing it must invalidate the hash. + h.Write([]byte(local.Venv)) + h.Write([]byte{0}) + // Sort env keys so hash is stable across map iteration orders. + keys := make([]string, 0, len(local.Env)) + for k := range local.Env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + h.Write([]byte(k)) + h.Write([]byte{'='}) + h.Write([]byte(local.Env[k])) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil))[:16] +} + +// resolveWorkdir returns the absolute working directory for a local service. +// Relative paths are resolved against the workspace root. +func resolveWorkdir(root string, local *ServiceLocal) string { + if local.Workdir == "" { + return root + } + if filepath.IsAbs(local.Workdir) { + return local.Workdir + } + return filepath.Join(root, local.Workdir) +} + +// resolveLocalCommand returns the executable path LocalStart would actually +// invoke for the given CRD — post-venv lookup. Shared with LocalStatusWithCRD +// so adoption of a legacy PID file compares the *same* argv the supervisor +// would produce on a fresh start. Extracted to keep both call sites in sync. +func resolveLocalCommand(root string, local *ServiceLocal) string { + command := local.Command + if venv := resolveVenv(root, local); venv != "" && !filepath.IsAbs(command) { + candidate := filepath.Join(venv, "bin", command) + if _, err := os.Stat(candidate); err == nil { + command = candidate + } + } + return command +} + +// resolveVenv returns the absolute venv path (or empty string). +func resolveVenv(root string, local *ServiceLocal) string { + if local.Venv == "" { + return "" + } + if filepath.IsAbs(local.Venv) { + return local.Venv + } + return filepath.Join(root, local.Venv) +} + +// buildLocalEnv merges the parent env with venv adjustments and CRD-declared +// overrides. Venv bin/ is prepended to PATH and VIRTUAL_ENV is set so Python +// tooling behaves as if activated. +func buildLocalEnv(root string, local *ServiceLocal) []string { + env := os.Environ() + + if venv := resolveVenv(root, local); venv != "" { + binDir := filepath.Join(venv, "bin") + out := make([]string, 0, len(env)+2) + pathSet := false + for _, e := range env { + if strings.HasPrefix(e, "PATH=") { + out = append(out, "PATH="+binDir+string(os.PathListSeparator)+strings.TrimPrefix(e, "PATH=")) + pathSet = true + } else if strings.HasPrefix(e, "VIRTUAL_ENV=") { + continue + } else { + out = append(out, e) + } + } + if !pathSet { + out = append(out, "PATH="+binDir) + } + out = append(out, "VIRTUAL_ENV="+venv) + env = out + } + + for k, v := range local.Env { + env = append(env, k+"="+v) + } + return env +} + +// ─── PID file I/O ─────────────────────────────────────────────────────────────── + +func readLocalProcess(root, name string) (*LocalProcess, error) { + data, err := os.ReadFile(localPIDPath(root, name)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read pid file for %s: %w", name, err) + } + var p LocalProcess + if err := json.Unmarshal(data, &p); err != nil { + return nil, fmt.Errorf("parse pid file for %s: %w", name, err) + } + return &p, nil +} + +func writeLocalProcess(root string, p *LocalProcess) error { + dir := localServicesDir(root) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + return os.WriteFile(localPIDPath(root, p.Name), data, 0o644) +} + +func removeLocalPID(root, name string) { + _ = os.Remove(localPIDPath(root, name)) +} + +// processAlive returns true if PID is a live process owned by this user. +// signal 0 delivers no signal but performs error checking. +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + if runtime.GOOS == "windows" { + // FindProcess on Windows doesn't fail for dead PIDs; treat as alive. + return true + } + return proc.Signal(syscall.Signal(0)) == nil +} + +// psLookup is the seam used by verifyOwnership to read a process's argv. +// Tests override it; the production implementation shells out to `ps`. +// +// Returns the verbatim argv string (single line, fields joined by spaces) +// or an error if the process is gone or ps fails. +var psLookup = func(pid int) (string, error) { + out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "args=").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// verifyOwnership reports whether the live process at pid is the one we +// started for this service, by comparing its argv against the cmd/args we +// recorded at LocalStart time. Used as a guard before sending signals so we +// don't kill an unrelated process that reused this PID after a crash or +// reboot. +// +// Returns: +// - (true, nil) when argv matches recorded cmd+args. +// - (false, nil) when the process is gone, ps refuses to look it up, or +// argv differs from what we expect (PID reuse, replacement, etc.). +// - (false, err) only on unexpected internal errors. Callers should treat +// non-nil err as "do not signal" — same as a false ownership result. +// +// The expectedCmd is the resolved binary path LocalStart used (post-venv +// lookup), and expectedArgs is the argv tail. An empty expectedCmd is +// treated as a legacy / unrecorded PID file; we refuse ownership in that +// case so the caller declines to signal — restart the service to repopulate. +// +// On Windows, ps is unavailable so we fall back to the liveness check. +func verifyOwnership(pid int, expectedCmd string, expectedArgs []string) (bool, error) { + if pid <= 0 { + return false, nil + } + if runtime.GOOS == "windows" { + // No portable ps; fall back to liveness — windows is not a primary + // platform for the local runner today, but we don't want to leak + // processes there either. + return processAlive(pid), nil + } + if expectedCmd == "" { + // Legacy PID file with no recorded argv — we can't verify, and the + // safe default is to refuse signals. The caller will log + clear. + return false, nil + } + + actual, err := psLookup(pid) + if err != nil { + // Most common case: process doesn't exist → ps exits non-zero. + // Treat as "not ours" so the caller stops without signaling. + return false, nil + } + if actual == "" { + return false, nil + } + + expected := expectedCmd + for _, a := range expectedArgs { + expected += " " + a + } + return actual == strings.TrimSpace(expected), nil +} + +// ─── Public API ───────────────────────────────────────────────────────────────── + +// LocalStatus returns the tracked process for the named service, or nil if +// no PID file exists. The Running field is populated by verifying the PID +// is alive AND that its argv matches what we recorded — protecting against +// PID reuse misreporting an unrelated process as our service. +// +// Stale PID files (dead process or argv mismatch) are cleaned up +// automatically so the next reconcile treats this as a create. +// +// This form has no CRD context, so legacy PID files (written before Cmd/Args +// were recorded) cannot be adopted — they get cleared. Callers that *do* have +// the CRD in hand should use LocalStatusWithCRD instead: it falls back to +// re-deriving the expected argv from the CRD and adopting the live process +// when it matches. Used by FetchLive during reconcile so in-place kernel +// upgrades don't start a duplicate alongside an already-running service. +func LocalStatus(root, name string) (*LocalProcess, error) { + return LocalStatusWithCRD(root, name, nil) +} + +// LocalStatusWithCRD returns the tracked process for the named service, with +// an optional CRD used to recover from legacy PID files. Preserves all the +// strict-match guarantees of LocalStatus; only the "empty recorded cmd" case +// is changed: when a CRD is supplied and the live process's argv matches what +// the CRD would resolve to, the runner adopts the process (writes a fresh PID +// file with the recorded cmd/args) so subsequent calls use the fast path. +// +// Callers without the CRD (e.g. raw `cog service status` dumps) should keep +// using LocalStatus — adoption is reconciliation-time behavior, not a general +// status-read concern. +func LocalStatusWithCRD(root, name string, crd *ServiceCRD) (*LocalProcess, error) { + p, err := readLocalProcess(root, name) + if err != nil || p == nil { + return p, err + } + if !processAlive(p.PID) { + removeLocalPID(root, name) + return p, nil + } + + // Fast path: PID file has a recorded argv → verify against live argv. + if p.Cmd != "" { + owns, _ := verifyOwnership(p.PID, p.Cmd, p.Args) + if !owns { + log.Printf("[local-runner] warn: PID %d for service %s does not match recorded argv (cmd=%q); clearing stale PID file", + p.PID, name, p.Cmd) + removeLocalPID(root, name) + return p, nil + } + p.Running = true + return p, nil + } + + // Legacy PID file (empty Cmd) — written before LocalProcess tracked argv. + // Without a CRD we can't verify ownership, so fall back to the old strict + // refusal: clear it and let the reconciler recreate the service. + if crd == nil || crd.Spec.Local == nil { + log.Printf("[local-runner] warn: PID %d for service %s has no recorded argv and no CRD available for adoption; clearing stale PID file", + p.PID, name) + removeLocalPID(root, name) + return p, nil + } + + // Adoption path: re-derive the expected argv from the CRD and compare + // against the live process. If it matches, the PID really is ours — we + // just didn't know it because the PID file predates argv tracking. Write + // a fresh PID file with the resolved cmd so future calls take the fast + // path above (no CRD needed). + expectedCmd := resolveLocalCommand(root, crd.Spec.Local) + expectedArgs := crd.Spec.Local.Args + owns, _ := verifyOwnership(p.PID, expectedCmd, expectedArgs) + if !owns { + log.Printf("[local-runner] warn: PID %d for service %s has legacy PID file and live argv does not match CRD (expected cmd=%q); clearing stale PID file", + p.PID, name, expectedCmd) + removeLocalPID(root, name) + return p, nil + } + + log.Printf("[local-runner] adopting legacy PID %d for service %s (argv matches CRD cmd=%q)", + p.PID, name, expectedCmd) + p.Cmd = expectedCmd + p.Args = append([]string(nil), expectedArgs...) + p.Running = true + if err := writeLocalProcess(root, p); err != nil { + // Non-fatal — next reconcile will try again. Return Running=true + // regardless since the live process is genuinely ours. + log.Printf("[local-runner] warn: adoption succeeded but PID file rewrite failed for %s: %v", name, err) + } + return p, nil +} + +// LocalStart launches the service in a detached process group, writes the +// PID file, and returns immediately. Logs stream to +// .cog/run/services/.log. The caller must ensure no other process is +// already running under this service's PID file. +func LocalStart(root string, crd *ServiceCRD) (*LocalProcess, error) { + if crd.Spec.Local == nil { + return nil, fmt.Errorf("service %s: no local spec", crd.Metadata.Name) + } + local := crd.Spec.Local + + workdir := resolveWorkdir(root, local) + if fi, err := os.Stat(workdir); err != nil || !fi.IsDir() { + return nil, fmt.Errorf("service %s: workdir %s not a directory", crd.Metadata.Name, workdir) + } + + if err := os.MkdirAll(localServicesDir(root), 0o755); err != nil { + return nil, fmt.Errorf("service %s: create run dir: %w", crd.Metadata.Name, err) + } + + logFile, err := os.OpenFile(localLogPath(root, crd.Metadata.Name), + os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return nil, fmt.Errorf("service %s: open log: %w", crd.Metadata.Name, err) + } + defer logFile.Close() + + fmt.Fprintf(logFile, "\n=== %s started at %s ===\n", crd.Metadata.Name, nowISO()) + + // Resolve command against the venv's bin/ when non-absolute. Shared with + // LocalStatusWithCRD so adoption of legacy PID files compares argv the + // exact same way. + command := resolveLocalCommand(root, local) + + cmd := exec.Command(command, local.Args...) + cmd.Dir = workdir + cmd.Env = buildLocalEnv(root, local) + cmd.Stdout = logFile + cmd.Stderr = logFile + // Detach stdin. Some Python MCP servers inspect stdin and exit if it's + // not a pipe/TTY; we give them /dev/null so nothing blocks and nothing + // triggers stdio-mode logic in the child. + if devnull, err := os.Open(os.DevNull); err == nil { + cmd.Stdin = devnull + defer devnull.Close() + } + // Detach from the kernel's session/process group so kernel shutdown + // (or any signal we propagate) doesn't cascade to supervised services. + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("service %s: start: %w", crd.Metadata.Name, err) + } + + // Release the child so we don't accumulate zombie state in the kernel. + go func(p *os.Process) { _, _ = p.Wait() }(cmd.Process) + + proc := &LocalProcess{ + Name: crd.Metadata.Name, + PID: cmd.Process.Pid, + StartedAt: nowISO(), + CmdHash: localCmdHash(local, workdir), + Workdir: workdir, + LogPath: localLogPath(root, crd.Metadata.Name), + Cmd: command, + Args: append([]string(nil), local.Args...), + Running: true, + } + if err := writeLocalProcess(root, proc); err != nil { + // Best-effort: kill the orphan we can't track. + _ = cmd.Process.Kill() + return nil, err + } + return proc, nil +} + +// LocalStop sends SIGTERM, waits up to 10s, then SIGKILL. The PID file is +// removed regardless of outcome — either the process is gone or we've lost +// confidence that it is ours. +// +// Before signaling, the live process's argv is verified against the recorded +// cmd+args. If they don't match (PID reuse after a crash/reboot, manual +// replacement, etc.), we log a warning and clear the stale PID file *without +// sending any signal* — otherwise we'd kill an unrelated user process and, +// worse, its whole process group, since we start with Setsid. +func LocalStop(root, name string) error { + p, err := readLocalProcess(root, name) + if err != nil { + return err + } + if p == nil { + return nil + } + defer removeLocalPID(root, name) + + if !processAlive(p.PID) { + return nil + } + + owns, _ := verifyOwnership(p.PID, p.Cmd, p.Args) + if !owns { + log.Printf("[local-runner] warn: refusing to signal PID %d for service %s — argv does not match recorded cmd=%q (likely PID reuse). Clearing stale PID file without signaling.", + p.PID, name, p.Cmd) + return nil + } + + proc, err := os.FindProcess(p.PID) + if err != nil { + return nil + } + + // Signal the whole process group since we start with Setsid. + _ = syscall.Kill(-p.PID, syscall.SIGTERM) + _ = proc.Signal(syscall.SIGTERM) + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if !processAlive(p.PID) { + return nil + } + time.Sleep(250 * time.Millisecond) + } + + _ = syscall.Kill(-p.PID, syscall.SIGKILL) + _ = proc.Signal(syscall.SIGKILL) + return nil +} + +// ListLocalProcesses scans .cog/run/services/ for tracked services and +// returns their live status. Useful for reconcile orphan detection. +// +// No CRD map is consulted, so legacy PID files without recorded argv are +// cleared rather than adopted. Call sites that have CRDs in hand (reconcile's +// FetchLive) should use ListLocalProcessesWithCRDs instead to preserve live +// services across in-place kernel upgrades. +func ListLocalProcesses(root string) (map[string]*LocalProcess, error) { + return ListLocalProcessesWithCRDs(root, nil) +} + +// ListLocalProcessesWithCRDs scans the PID directory and reports live status +// per service, consulting the provided CRD map for adoption of legacy PID +// files. The map is keyed by service name; entries absent from the map use +// the strict (non-adopting) code path. +// +// nil map is equivalent to ListLocalProcesses: no adoption attempts. +func ListLocalProcessesWithCRDs(root string, crds map[string]*ServiceCRD) (map[string]*LocalProcess, error) { + entries, err := os.ReadDir(localServicesDir(root)) + if err != nil { + if os.IsNotExist(err) { + return map[string]*LocalProcess{}, nil + } + return nil, err + } + + out := make(map[string]*LocalProcess) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".pid") { + continue + } + name := strings.TrimSuffix(e.Name(), ".pid") + var crd *ServiceCRD + if crds != nil { + crd = crds[name] + } + p, err := LocalStatusWithCRD(root, name, crd) + if err != nil || p == nil { + continue + } + out[name] = p + } + return out, nil +} diff --git a/local_runner_test.go b/local_runner_test.go new file mode 100644 index 0000000..b78cfda --- /dev/null +++ b/local_runner_test.go @@ -0,0 +1,348 @@ +// local_runner_test.go — Coverage for the LocalRunner ownership-verification +// path and the cmd-hash hashing surface. +// +// These tests do NOT spawn real processes; they exercise the unit logic of +// verifyOwnership via the psLookup seam and localCmdHash via direct calls. +// The adoption test uses os.Getpid() so processAlive() returns true without +// fork/exec gymnastics, and feeds the expected argv through the psLookup seam. + +package main + +import ( + "errors" + "os" + "runtime" + "testing" +) + +// withPSLookup swaps psLookup for the duration of fn, restoring it after. +// Keeps tests isolated from the real /bin/ps so they don't depend on what +// PIDs happen to exist on the test runner. +func withPSLookup(stub func(pid int) (string, error), fn func()) { + orig := psLookup + psLookup = stub + defer func() { psLookup = orig }() + fn() +} + +func TestVerifyOwnership(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("verifyOwnership uses ps, which isn't the production path on windows") + } + + cases := []struct { + name string + pid int + expectedCmd string + expectedArgs []string + psStub func(pid int) (string, error) + want bool + }{ + { + name: "matches cmd with no args", + pid: 1234, + expectedCmd: "/usr/bin/python3", + expectedArgs: nil, + psStub: func(pid int) (string, error) { + return "/usr/bin/python3", nil + }, + want: true, + }, + { + name: "matches cmd with args", + pid: 1234, + expectedCmd: "/opt/venv/bin/python3", + expectedArgs: []string{"server.py", "--port", "8080"}, + psStub: func(pid int) (string, error) { + return "/opt/venv/bin/python3 server.py --port 8080", nil + }, + want: true, + }, + { + name: "mismatched cmd (PID reuse) → false", + pid: 1234, + expectedCmd: "/opt/venv/bin/python3", + expectedArgs: []string{"server.py"}, + psStub: func(pid int) (string, error) { + // User's bash shell happens to have reused PID 1234. + return "-zsh", nil + }, + want: false, + }, + { + name: "mismatched args → false", + pid: 1234, + expectedCmd: "/usr/bin/python3", + expectedArgs: []string{"server.py", "--port", "8080"}, + psStub: func(pid int) (string, error) { + return "/usr/bin/python3 server.py --port 9090", nil + }, + want: false, + }, + { + name: "ps says no such process → false (treated as dead)", + pid: 1234, + expectedCmd: "/usr/bin/python3", + expectedArgs: []string{"server.py"}, + psStub: func(pid int) (string, error) { + return "", errors.New("ps: no such process") + }, + want: false, + }, + { + name: "empty ps output → false", + pid: 1234, + expectedCmd: "/usr/bin/python3", + expectedArgs: nil, + psStub: func(pid int) (string, error) { + return " ", nil + }, + want: false, + }, + { + name: "pid <= 0 → false without consulting ps", + pid: 0, + expectedCmd: "/usr/bin/python3", + expectedArgs: nil, + psStub: func(pid int) (string, error) { + t.Fatalf("ps should not be consulted for pid<=0") + return "", nil + }, + want: false, + }, + { + name: "legacy PID file (no recorded cmd) → false", + pid: 1234, + expectedCmd: "", + expectedArgs: nil, + psStub: func(pid int) (string, error) { + t.Fatalf("ps should not be consulted when expectedCmd is empty") + return "/usr/bin/anything", nil + }, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + withPSLookup(tc.psStub, func() { + got, err := verifyOwnership(tc.pid, tc.expectedCmd, tc.expectedArgs) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("verifyOwnership(%d, %q, %v) = %v, want %v", + tc.pid, tc.expectedCmd, tc.expectedArgs, got, tc.want) + } + }) + }) + } +} + +// legacyPIDFile is the on-disk shape older kernel builds wrote before we +// started recording Cmd/Args on LocalProcess. Emulating it in tests lets us +// exercise the adoption path without a migration dance. +func writeLegacyPIDFile(t *testing.T, root, name string, pid int) { + t.Helper() + legacy := &LocalProcess{ + Name: name, + PID: pid, + StartedAt: "2026-01-01T00:00:00Z", + CmdHash: "legacy-hash", + Workdir: root, + LogPath: localLogPath(root, name), + // Cmd and Args intentionally empty — this is what older PID files + // on disk look like, and what LocalStatusWithCRD must handle. + } + if err := writeLocalProcess(root, legacy); err != nil { + t.Fatalf("write legacy PID file: %v", err) + } +} + +// TestLocalStatusWithCRD_AdoptsLegacyPIDFile covers the in-place upgrade +// recovery path: older kernel wrote a PID file without Cmd/Args, the service +// is still running, and the current kernel must adopt it — not clear the PID +// file and start a duplicate alongside the live service. +// +// The test uses os.Getpid() so processAlive() returns true without spawning, +// and routes the ps argv lookup through the psLookup seam so the assertion is +// hermetic (no dependency on what `ps -p $OS_PID -o args=` actually emits). +func TestLocalStatusWithCRD_AdoptsLegacyPIDFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("adoption path uses ps, which isn't the production path on windows") + } + + root := t.TempDir() + name := "myservice" + pid := os.Getpid() // always alive and owned by this process + writeLegacyPIDFile(t, root, name, pid) + + crd := &ServiceCRD{ + Metadata: ServiceCRDMeta{Name: name}, + Spec: ServiceCRDSpec{ + Local: &ServiceLocal{ + Command: "/usr/bin/python3", + Args: []string{"server.py", "--port", "9090"}, + }, + }, + } + expectedArgv := "/usr/bin/python3 server.py --port 9090" + + withPSLookup(func(lookupPID int) (string, error) { + if lookupPID != pid { + t.Fatalf("ps consulted for unexpected pid %d (want %d)", lookupPID, pid) + } + return expectedArgv, nil + }, func() { + p, err := LocalStatusWithCRD(root, name, crd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p == nil { + t.Fatalf("expected non-nil LocalProcess after adoption") + } + if !p.Running { + t.Errorf("Running = false, want true after adoption") + } + if p.Cmd != "/usr/bin/python3" { + t.Errorf("returned Cmd = %q, want /usr/bin/python3 (post-adoption)", p.Cmd) + } + want := []string{"server.py", "--port", "9090"} + if len(p.Args) != len(want) { + t.Fatalf("Args len = %d, want %d (%v)", len(p.Args), len(want), p.Args) + } + for i := range want { + if p.Args[i] != want[i] { + t.Errorf("Args[%d] = %q, want %q", i, p.Args[i], want[i]) + } + } + }) + + // Verify the PID file was rewritten with the recorded cmd+args so + // subsequent calls take the strict fast path and no longer need the CRD. + rewritten, err := readLocalProcess(root, name) + if err != nil { + t.Fatalf("read rewritten PID file: %v", err) + } + if rewritten == nil { + t.Fatalf("PID file was cleared instead of rewritten") + } + if rewritten.Cmd != "/usr/bin/python3" { + t.Errorf("rewritten Cmd = %q, want /usr/bin/python3", rewritten.Cmd) + } + if len(rewritten.Args) != 3 { + t.Errorf("rewritten Args = %v, want 3 elements", rewritten.Args) + } +} + +// Negative case: legacy PID file + CRD whose expected argv does NOT match the +// live process. Must clear the PID file (old safe-default) — adopting a +// mismatched process would let a crashed-and-PID-reused scenario slip through. +func TestLocalStatusWithCRD_ClearsLegacyPIDOnArgvMismatch(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("adoption path uses ps, which isn't the production path on windows") + } + + root := t.TempDir() + name := "mismatched" + pid := os.Getpid() + writeLegacyPIDFile(t, root, name, pid) + + crd := &ServiceCRD{ + Metadata: ServiceCRDMeta{Name: name}, + Spec: ServiceCRDSpec{ + Local: &ServiceLocal{ + Command: "/usr/bin/python3", + Args: []string{"server.py"}, + }, + }, + } + + withPSLookup(func(lookupPID int) (string, error) { + // Live process is some unrelated shell — definitely not our service. + return "-zsh", nil + }, func() { + p, err := LocalStatusWithCRD(root, name, crd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p == nil { + t.Fatalf("readLocalProcess should have returned the pre-clear struct") + } + if p.Running { + t.Errorf("Running = true, want false (argv mismatch must not adopt)") + } + }) + + // PID file must be gone so the next reconcile creates a fresh process. + if _, statErr := os.Stat(localPIDPath(root, name)); !os.IsNotExist(statErr) { + t.Errorf("legacy PID file should have been cleared (argv mismatch); stat err=%v", statErr) + } +} + +// Legacy PID file + no CRD → preserves original strict behavior (clear). +func TestLocalStatusWithCRD_NilCRDClearsLegacyPID(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("adoption path uses ps, which isn't the production path on windows") + } + + root := t.TempDir() + name := "nocrd" + pid := os.Getpid() + writeLegacyPIDFile(t, root, name, pid) + + // psLookup must NOT be consulted at all — without a CRD we can't form an + // expected argv, so there's nothing to compare. Fail loudly if called. + withPSLookup(func(lookupPID int) (string, error) { + t.Fatalf("ps should not be consulted when crd is nil") + return "", nil + }, func() { + p, err := LocalStatusWithCRD(root, name, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p != nil && p.Running { + t.Errorf("Running = true, want false (no CRD must clear legacy)") + } + }) + + if _, statErr := os.Stat(localPIDPath(root, name)); !os.IsNotExist(statErr) { + t.Errorf("legacy PID file should have been cleared when CRD is nil; stat err=%v", statErr) + } + + // Unused import guard: errors used implicitly via withPSLookup/psLookup stubs. + _ = errors.New("") +} + +func TestLocalCmdHashIncludesVenv(t *testing.T) { + base := &ServiceLocal{ + Command: "python3", + Args: []string{"server.py", "--port", "8080"}, + Env: map[string]string{"FOO": "bar"}, + } + withVenv := *base + withVenv.Venv = ".venv" + + hashWithoutVenv := localCmdHash(base, "/workspace") + hashWithVenv := localCmdHash(&withVenv, "/workspace") + + if hashWithoutVenv == hashWithVenv { + t.Fatalf("localCmdHash must differ when venv changes; got %q for both", + hashWithoutVenv) + } + + // Sanity: same inputs produce the same hash (stable). + again := localCmdHash(base, "/workspace") + if again != hashWithoutVenv { + t.Fatalf("localCmdHash not stable across calls: %q vs %q", again, hashWithoutVenv) + } + + // Swapping the venv to a different path also flips the hash. + otherVenv := *base + otherVenv.Venv = "/abs/other/.venv" + otherHash := localCmdHash(&otherVenv, "/workspace") + if otherHash == hashWithVenv { + t.Fatalf("localCmdHash must differ across distinct venv paths; got %q for both", + otherHash) + } +} diff --git a/mcp.go b/mcp.go index 82dd749..784d092 100644 --- a/mcp.go +++ b/mcp.go @@ -473,6 +473,10 @@ type MCPServer struct { // Tracing — propagated from parent process via TRACEPARENT env var traceCtx context.Context + + // Bus integration (HTTP mode only — nil for stdio) + busManager *busSessionManager + busID string } // NewMCPServer creates a new MCP server @@ -485,6 +489,22 @@ func NewMCPServer(root string, reader io.Reader, writer io.Writer) *MCPServer { } } +// NewMCPServerForHTTP creates an MCPServer for HTTP transport (no stdin/stdout). +// Used by MCPSessionManager to create per-session servers. +func NewMCPServerForHTTP(root string, busManager *busSessionManager) *MCPServer { + return &MCPServer{ + root: root, + debug: os.Getenv("MCP_DEBUG") != "", + busManager: busManager, + } +} + +// HandleRequest dispatches a JSON-RPC request and returns the result. +// This is the public entry point used by both the stdio loop and the HTTP transport. +func (s *MCPServer) HandleRequest(req *JSONRPCRequest) (interface{}, *JSONRPCError) { + return s.handleRequest(req) +} + // EnableBridge activates bridge mode with the given OpenClaw connection func (s *MCPServer) EnableBridge(bridge *OpenClawBridge) { s.bridge = bridge @@ -587,7 +607,7 @@ func (s *MCPServer) handleInitialize(params json.RawMessage) (interface{}, *JSON } return &MCPInitializeResult{ - ProtocolVersion: "2024-11-05", + ProtocolVersion: "2025-03-26", Capabilities: MCPServerCaps{ Resources: &MCPResourcesCaps{ Subscribe: false, @@ -1045,12 +1065,15 @@ func GetMCPTools() []MCPTool { } } -// handleToolsList returns available tools, merging local + external in bridge mode. +// handleToolsList returns available tools, merging local + Mod3 + external in bridge mode. // External tools come from the tool registry (populated from the request body's tools field), // not from an HTTP discovery endpoint. func (s *MCPServer) handleToolsList(params json.RawMessage) (interface{}, *JSONRPCError) { tools := GetMCPTools() + // Always include Mod3 tools (they gracefully degrade if Mod3 is unreachable) + tools = append(tools, GetMod3Tools()...) + if s.bridgeOn && len(s.externalTools) > 0 { for _, rt := range s.externalTools { // Namespace external tools with openclaw_ prefix @@ -1124,6 +1147,17 @@ func (s *MCPServer) handleToolsCall(params json.RawMessage) (interface{}, *JSONR result, rpcErr = s.toolMemoryWrite(p.Arguments) case "cogos_coherence_check": result, rpcErr = s.toolCoherenceCheck(p.Arguments) + + // Mod3 tools — forwarded to Mod3 HTTP API + case "mod3_speak": + result, rpcErr = toolMod3Speak(p.Arguments) + case "mod3_stop": + result, rpcErr = toolMod3Stop(p.Arguments) + case "mod3_voices": + result, rpcErr = toolMod3Voices(p.Arguments) + case "mod3_status": + result, rpcErr = toolMod3Status(p.Arguments) + default: span.SetStatus(codes.Error, "unknown tool") return nil, &JSONRPCError{Code: MethodNotFound, Message: fmt.Sprintf("Unknown tool: %s", p.Name)} diff --git a/mcp_http.go.wip b/mcp_http.go similarity index 100% rename from mcp_http.go.wip rename to mcp_http.go diff --git a/mcp_http_test.go.wip b/mcp_http_test.go similarity index 83% rename from mcp_http_test.go.wip rename to mcp_http_test.go index d51536c..979255f 100644 --- a/mcp_http_test.go.wip +++ b/mcp_http_test.go @@ -3,7 +3,6 @@ package main import ( "context" "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" @@ -758,143 +757,7 @@ func newBusWorkspace(t *testing.T) (*MCPSessionManager, *workspaceContext) { return m, ws } -func TestMCPHTTP_ToolCallEmitsBusEvents(t *testing.T) { - m, ws := newBusWorkspace(t) - sessionID := initializeWithWorkspace(t, m, ws) - - // Call cogos_coherence_check (lightweight, no side effects) - body := `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"cogos_coherence_check","arguments":{}}}` - resp := postMCP(t, m, sessionID, body) - if resp.StatusCode != http.StatusOK { - t.Fatalf("tool call: status=%d, want 200", resp.StatusCode) - } - - var rpc JSONRPCResponse - json.NewDecoder(resp.Body).Decode(&rpc) - if rpc.Error != nil { - t.Fatalf("tool call: RPC error: %+v", rpc.Error) - } - - // Read the session's bus to verify tool.invoke + tool.result events - busMgr := ws.busChat.manager - busID := fmt.Sprintf("bus_mcp_%s", sessionID) - events, err := busMgr.readBusEvents(busID) - if err != nil { - t.Fatalf("read bus events: %v", err) - } - - // Expect: mcp.session.init, tool.invoke, tool.result (at minimum) - var hasInit, hasInvoke, hasResult bool - var invokeSeq, resultSeq int - for _, evt := range events { - switch evt.Type { - case "mcp.session.init": - hasInit = true - case "tool.invoke": - hasInvoke = true - invokeSeq = evt.Seq - // Verify tool name in payload - if tool, _ := evt.Payload["tool"].(string); tool != "cogos_coherence_check" { - t.Errorf("tool.invoke payload tool=%q, want cogos_coherence_check", tool) - } - case "tool.result": - hasResult = true - resultSeq = evt.Seq - // Verify tool name in payload - if tool, _ := evt.Payload["tool"].(string); tool != "cogos_coherence_check" { - t.Errorf("tool.result payload tool=%q, want cogos_coherence_check", tool) - } - } - } - - if !hasInit { - t.Error("missing mcp.session.init event on bus") - } - if !hasInvoke { - t.Error("missing tool.invoke event on bus") - } - if !hasResult { - t.Error("missing tool.result event on bus") - } - if hasInvoke && hasResult && resultSeq <= invokeSeq { - t.Errorf("tool.result seq (%d) should be after tool.invoke seq (%d)", resultSeq, invokeSeq) - } - - // Verify hash chain integrity — each event's prev should reference the prior event - for i := 1; i < len(events); i++ { - if len(events[i].Prev) == 0 { - t.Errorf("event seq=%d has empty prev, expected hash chain link", events[i].Seq) - } else if events[i].Prev[0] != events[i-1].Hash { - t.Errorf("event seq=%d prev[0]=%s, want %s (prev event hash)", events[i].Seq, events[i].Prev[0], events[i-1].Hash) - } - } -} - -func TestMCPHTTP_BusSendRead(t *testing.T) { - m, ws := newBusWorkspace(t) - sessionID := initializeWithWorkspace(t, m, ws) - - // Send a custom event via cogos_bus_send - sendBody := `{"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"cogos_bus_send","arguments":{"type":"test.signal","payload":{"msg":"hello from MCP"}}}}` - sendResp := postMCP(t, m, sessionID, sendBody) - if sendResp.StatusCode != http.StatusOK { - t.Fatalf("bus_send: status=%d, want 200", sendResp.StatusCode) - } - - var sendRPC JSONRPCResponse - json.NewDecoder(sendResp.Body).Decode(&sendRPC) - if sendRPC.Error != nil { - t.Fatalf("bus_send: RPC error: %+v", sendRPC.Error) - } - - // Read back via cogos_bus_read - readBody := `{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"cogos_bus_read","arguments":{"type_filter":"test.signal"}}}` - readResp := postMCP(t, m, sessionID, readBody) - if readResp.StatusCode != http.StatusOK { - t.Fatalf("bus_read: status=%d, want 200", readResp.StatusCode) - } - - var readRPC JSONRPCResponse - json.NewDecoder(readResp.Body).Decode(&readRPC) - if readRPC.Error != nil { - t.Fatalf("bus_read: RPC error: %+v", readRPC.Error) - } - - // Parse the tool result - resultBytes, _ := json.Marshal(readRPC.Result) - var toolResult MCPToolCallResult - json.Unmarshal(resultBytes, &toolResult) - if toolResult.IsError { - t.Fatalf("bus_read: isError=true") - } - if len(toolResult.Content) == 0 { - t.Fatal("bus_read: no content") - } - - // Parse the events from the content text - var events []CogBlock - if err := json.Unmarshal([]byte(toolResult.Content[0].Text), &events); err != nil { - t.Fatalf("bus_read: unmarshal events: %v", err) - } - - if len(events) == 0 { - t.Fatal("bus_read: no test.signal events returned") - } - - // Verify our custom event is in there - found := false - for _, evt := range events { - if evt.Type == "test.signal" { - if msg, _ := evt.Payload["msg"].(string); msg == "hello from MCP" { - found = true - } - // Verify hash is non-empty - if evt.Hash == "" { - t.Error("bus_read: event has empty hash") - } - } - } - if !found { - t.Error("bus_read: did not find test.signal event with expected payload") - } -} +// Two aspirational tests removed (2026-04-15): +// - TestMCPHTTP_ToolCallEmitsBusEvents: tool.invoke/tool.result bus emission +// from MCP handler — never implemented. Tool router handles this path. +// - TestMCPHTTP_BusSendRead: cogos_bus_send/cogos_bus_read tools — never built. diff --git a/mcp_mod3.go b/mcp_mod3.go new file mode 100644 index 0000000..740e402 --- /dev/null +++ b/mcp_mod3.go @@ -0,0 +1,260 @@ +// MCP Mod3 Tools — forwards Mod3 TTS tools through the kernel MCP endpoint. +// +// Mod3 runs as an HTTP service (default: localhost:7860). These tools proxy +// MCP tool calls to the Mod3 REST API, allowing remote MCP clients to use +// voice synthesis without direct access to the Mod3 service. +// +// Tools: +// - mod3_speak → POST /v1/synthesize (returns JSON metrics, not audio) +// - mod3_stop → POST /v1/stop +// - mod3_voices → GET /v1/voices +// - mod3_status → GET /health + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" +) + +// mod3BaseURL returns the Mod3 service URL from env or default. +func mod3BaseURL() string { + if u := os.Getenv("MOD3_URL"); u != "" { + return u + } + return "http://localhost:7860" +} + +var mod3Client = &http.Client{ + Timeout: 30 * time.Second, +} + +// GetMod3Tools returns the MCP tool definitions for Mod3. +func GetMod3Tools() []MCPTool { + return []MCPTool{ + { + Name: "mod3_speak", + Description: "Synthesize text to speech via Mod3. Audio is played on the server's speakers. Returns generation metrics (duration, RTF, engine used).", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "text": map[string]interface{}{ + "type": "string", + "description": "Text to synthesize", + }, + "voice": map[string]interface{}{ + "type": "string", + "description": "Voice preset (default: bm_lewis). Use mod3_voices to list available voices.", + "default": "bm_lewis", + }, + "speed": map[string]interface{}{ + "type": "number", + "description": "Speed multiplier (default: 1.25)", + "default": 1.25, + }, + "emotion": map[string]interface{}{ + "type": "number", + "description": "Emotion/exaggeration intensity 0.0-1.0 (Chatterbox engine only, default: 0.5)", + "default": 0.5, + }, + }, + "required": []string{"text"}, + }, + }, + { + Name: "mod3_stop", + Description: "Stop current speech playback and/or cancel queued items on Mod3.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "job_id": map[string]interface{}{ + "type": "string", + "description": "Specific job ID to cancel. If omitted, interrupts current playback and clears the queue.", + }, + }, + }, + }, + { + Name: "mod3_voices", + Description: "List available TTS engines and their voice presets on Mod3.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, + { + Name: "mod3_status", + Description: "Check Mod3 service health — engine status, loaded models, queue depth.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, + } +} + +// toolMod3Speak forwards a synthesize request to Mod3. +// Returns JSON metrics (not audio bytes) since MCP is a text protocol. +func toolMod3Speak(args map[string]interface{}) (interface{}, *JSONRPCError) { + text, _ := args["text"].(string) + if text == "" { + return nil, &JSONRPCError{Code: InvalidParams, Message: "text is required"} + } + + voice := "bm_lewis" + if v, ok := args["voice"].(string); ok && v != "" { + voice = v + } + speed := 1.25 + if s, ok := args["speed"].(float64); ok { + speed = s + } + emotion := 0.5 + if e, ok := args["emotion"].(float64); ok { + emotion = e + } + + body, _ := json.Marshal(map[string]interface{}{ + "text": text, + "voice": voice, + "speed": speed, + "emotion": emotion, + "format": "wav", + }) + + resp, err := mod3Client.Post(mod3BaseURL()+"/v1/synthesize", "application/json", bytes.NewReader(body)) + if err != nil { + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: fmt.Sprintf("Mod3 unreachable: %v", err)}}, + IsError: true, + }, nil + } + defer resp.Body.Close() + + // The synthesize endpoint returns audio bytes with metrics in headers. + // Discard the audio (it plays on the server), return the metrics. + io.Copy(io.Discard, resp.Body) + + if resp.StatusCode != 200 { + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: fmt.Sprintf("Mod3 error: HTTP %d", resp.StatusCode)}}, + IsError: true, + }, nil + } + + result := map[string]interface{}{ + "status": "ok", + "job_id": resp.Header.Get("X-Mod3-Job-Id"), + "engine": resp.Header.Get("X-Mod3-Engine"), + "voice": resp.Header.Get("X-Mod3-Voice"), + "duration": resp.Header.Get("X-Mod3-Duration-Sec"), + "rtf": resp.Header.Get("X-Mod3-RTF"), + "gen_time": resp.Header.Get("X-Mod3-Gen-Time-Sec"), + } + + resultJSON, _ := json.MarshalIndent(result, "", " ") + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: string(resultJSON)}}, + }, nil +} + +// toolMod3Stop forwards a stop request to Mod3. +func toolMod3Stop(args map[string]interface{}) (interface{}, *JSONRPCError) { + jobID, _ := args["job_id"].(string) + + url := mod3BaseURL() + "/v1/stop" + if jobID != "" { + url += "?job_id=" + jobID + } + + resp, err := mod3Client.Post(url, "application/json", nil) + if err != nil { + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: fmt.Sprintf("Mod3 unreachable: %v", err)}}, + IsError: true, + }, nil + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: fmt.Sprintf("Mod3 error: HTTP %d: %s", resp.StatusCode, string(respBody))}}, + IsError: true, + }, nil + } + + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: string(respBody)}}, + }, nil +} + +// toolMod3Voices lists available voices from Mod3. +func toolMod3Voices(_ map[string]interface{}) (interface{}, *JSONRPCError) { + resp, err := mod3Client.Get(mod3BaseURL() + "/v1/voices") + if err != nil { + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: fmt.Sprintf("Mod3 unreachable: %v", err)}}, + IsError: true, + }, nil + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: fmt.Sprintf("Mod3 error: HTTP %d: %s", resp.StatusCode, string(respBody))}}, + IsError: true, + }, nil + } + + // Pretty-print the JSON + var parsed interface{} + if err := json.Unmarshal(respBody, &parsed); err == nil { + pretty, _ := json.MarshalIndent(parsed, "", " ") + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: string(pretty)}}, + }, nil + } + + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: string(respBody)}}, + }, nil +} + +// toolMod3Status checks Mod3 health. +func toolMod3Status(_ map[string]interface{}) (interface{}, *JSONRPCError) { + resp, err := mod3Client.Get(mod3BaseURL() + "/health") + if err != nil { + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: fmt.Sprintf("Mod3 unreachable: %v", err)}}, + IsError: true, + }, nil + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: fmt.Sprintf("Mod3 error: HTTP %d: %s", resp.StatusCode, string(respBody))}}, + IsError: true, + }, nil + } + + var parsed interface{} + if err := json.Unmarshal(respBody, &parsed); err == nil { + pretty, _ := json.MarshalIndent(parsed, "", " ") + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: string(pretty)}}, + }, nil + } + + return &MCPToolCallResult{ + Content: []MCPToolContent{{Type: "text", Text: string(respBody)}}, + }, nil +} diff --git a/mcp_stub.go b/mcp_stub.go deleted file mode 100644 index 4871b4b..0000000 --- a/mcp_stub.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import "net/http" - -// MCPSessionManager stub — full implementation in mcp_http.go (WIP). -type MCPSessionManager struct{} - -func NewMCPSessionManager(workspaces map[string]*workspaceContext, root string) *MCPSessionManager { - return &MCPSessionManager{} -} - -func (m *MCPSessionManager) ServeHTTP(w http.ResponseWriter, r *http.Request) { - http.Error(w, "MCP not available", http.StatusNotImplemented) -} - -func (m *MCPSessionManager) Stop() {} -func (m *MCPSessionManager) SessionCount() int { return 0 } diff --git a/reconcile_helpers.go b/reconcile_helpers.go new file mode 100644 index 0000000..6976f7e --- /dev/null +++ b/reconcile_helpers.go @@ -0,0 +1,21 @@ +// reconcile_helpers.go — small text helpers shared by main-package providers. +// +// These helpers previously lived in component_provider.go. That file moved +// to internal/providers/component/ as part of ADR-085 Wave 1a, taking a +// local copy of the helpers with it. This file retains the copies still +// needed by providers that remain at the apps/cogos root (currently +// service_provider.go). + +package main + +// joinReasons joins multiple drift reasons into a semicolon-separated string. +func joinReasons(reasons []string) string { + if len(reasons) == 0 { + return "unknown" + } + result := reasons[0] + for _, r := range reasons[1:] { + result += "; " + r + } + return result +} diff --git a/sdk/constellation/indexer.go b/sdk/constellation/indexer.go index 83c1fae..1a1a7e8 100644 --- a/sdk/constellation/indexer.go +++ b/sdk/constellation/indexer.go @@ -127,6 +127,47 @@ func (c *Constellation) IndexWorkspace() error { return indexErr } +// IndexFile indexes a single cogdoc file into the constellation. +// This is the public entry point for incremental indexing (e.g., after +// a decomposition stores a new CogDoc). It handles its own transaction, +// FTS rebuild for the affected document, and optional async embedding. +func (c *Constellation) IndexFile(path string) error { + tx, err := c.db.Begin() + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { + if err := tx.Rollback(); err != nil && err != sql.ErrTxDone { + fmt.Fprintf(os.Stderr, "Warning: IndexFile rollback: %v\n", err) + } + }() + + if err := c.indexCogdoc(tx, path); err != nil { + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit tx: %w", err) + } + + // Rebuild FTS to include the new/updated document + if err := c.rebuildFTS(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: FTS rebuild after IndexFile: %v\n", err) + } + + // Async embedding if configured + if c.embedClient != nil { + go func() { + indexer := NewEmbedIndexer(c, c.embedClient) + if _, err := indexer.BackfillAll(1); err != nil { + fmt.Fprintf(os.Stderr, "[embed-indexer] single-doc backfill error: %v\n", err) + } + }() + } + + return nil +} + // indexCogdoc parses and indexes a single cogdoc file. func (c *Constellation) indexCogdoc(tx *sql.Tx, path string) error { // Read file diff --git a/serve.go b/serve.go index 6d48c73..2c17b03 100644 --- a/serve.go +++ b/serve.go @@ -71,6 +71,7 @@ type serveServer struct { consumerReg *consumerRegistry // Server-side consumer cursor tracking (ADR-061) toolBridge *ToolBridge // Synchronous tool bridge for client-driven agent loops mcpManager *MCPSessionManager // MCP Streamable HTTP session manager + agent *ServeAgent // Homeostatic agent loop (nil if not started) researchMgr *researchManager // Research orchestration (nil if no workspace) pipeline *ModalityPipeline // Modality pipeline (nil if no workspace) fceMetrics *foveatedMetrics // Rolling metrics for foveated context requests @@ -227,6 +228,9 @@ func (s *serveServer) Start() error { mux.HandleFunc("/v1/sessions/", s.handleSessionContext) // Per-session context detail mux.HandleFunc("GET /v1/card", s.handleCard) // ADR-048: Kernel capability card mux.HandleFunc("POST /v1/tool-bridge/pending", s.handleToolBridgePending) // Synchronous tool bridge + mux.HandleFunc("GET /v1/agent/status", s.handleAgentStatus) // Homeostatic agent loop status + mux.HandleFunc("POST /v1/agent/trigger", s.handleAgentTrigger) // Manual cycle trigger + mux.HandleFunc("GET /v1/agent/traces", s.handleAgentTraces) // Cycle trace history mux.HandleFunc("/health", s.handleHealth) mux.HandleFunc("GET /v1/health/canary", s.handleHealthCanary) mux.HandleFunc("GET /v1/metrics/foveated", s.handleFoveatedMetrics) // B4: FCE quality metrics @@ -292,6 +296,7 @@ func (s *serveServer) Start() error { mux.HandleFunc("POST /v1/research/stop", s.handleResearchStop) mux.HandleFunc("GET /v1/research/results", s.handleResearchResults) + mux.HandleFunc("GET /dashboard", s.handleDashboardPage) // Embedded web dashboard mux.HandleFunc("/", s.handleRoot) addr := fmt.Sprintf("127.0.0.1:%d", s.port) diff --git a/serve_daemon.go b/serve_daemon.go index ec35c87..e22880e 100644 --- a/serve_daemon.go +++ b/serve_daemon.go @@ -566,15 +566,47 @@ func cmdServeForeground(port int) int { defer reconciler.Stop() } + // 7b. Install cycle-trace emitter (ADR-083). Wires the engine's + // TraceEmitter hook to the bus and stamps events with the active + // identity. Safe to call once; subsequent calls overwrite. + InstallTraceEmitter(server.busChat.manager, root) + + // 7c. Install dashboard chat inlet. Subscribes in-process to + // bus_dashboard_chat and forwards user messages into the engine's + // external-event channel. process is nil here — this daemon does + // not own an *engine.Process; the chat path becomes live once a + // process is bound via RebindDashboardInlet. The outbound response + // bus is ensured now so the respond tool can publish immediately. + InstallDashboardInlet(server.busChat.manager, nil) + // 8. Homeostatic agent loop (E4B via Ollama, 30-min cycle) agent := NewServeAgent(root) agent.SetBus(server.busChat.manager) + server.agent = agent // expose to status API if startErr := agent.Start(); startErr != nil { log.Printf("[agent] failed to start: %v", startErr) } else { defer agent.Stop() } + // 8b. Wake agent on non-system bus events + { + systemBusSet := map[string]bool{ + "bus_chat_system_capabilities": true, + "bus_chat_http": true, + "bus_agent_harness": true, + "bus_index": true, + "decompose": true, + } + server.busChat.manager.AddEventHandler("agent-waker", func(busID string, block *CogBlock) { + if systemBusSet[busID] { + return + } + agent.Wake() + }) + defer server.busChat.manager.RemoveEventHandler("agent-waker") + } + // 6b. Service health monitor: polls container health every 30s, emits bus events svcMonitor := NewServiceHealthMonitor(root, server.busChat.manager) svcMonitor.Start() @@ -766,6 +798,31 @@ func cmdServeForeground(port int) int { } } + // 7c-bis. Install dashboard-chat inlet handler on each workspace's busChat + // manager as well. The primary workspace already shares server.busChat + // (handler registered above at step 7c), but non-primary workspaces each + // get their own newBusChat(), and HTTP POST routes to whichever workspace + // resolves from the request. Without this, events on non-primary-workspace + // busChat managers would write to disk but never invoke the in-process + // handler. Dedupe by manager pointer so we don't double-register. + { + seen := map[*busSessionManager]bool{} + if server.busChat != nil { + seen[server.busChat.manager] = true + } + for wsName, ws := range server.workspaces { + if ws == nil || ws.busChat == nil || ws.busChat.manager == nil { + continue + } + if seen[ws.busChat.manager] { + continue + } + seen[ws.busChat.manager] = true + InstallDashboardInlet(ws.busChat.manager, nil) + log.Printf("[dashboard-inlet] registered on workspace %q manager", wsName) + } + } + // Initialize OpenTelemetry tracing (noop if OTEL_EXPORTER_OTLP_ENDPOINT is not set) tp, otelErr := initTracer() if otelErr != nil { diff --git a/serve_dashboard.go b/serve_dashboard.go new file mode 100644 index 0000000..b19b556 --- /dev/null +++ b/serve_dashboard.go @@ -0,0 +1,22 @@ +// serve_dashboard.go — serves the embedded web dashboard at GET /dashboard. +// +// Re-embeds internal/engine/web/dashboard.html so the main kernel server +// (port 6931) can serve the interactive dashboard directly. The cogctl +// dashboard iframe points here. + +package main + +import ( + _ "embed" + "net/http" +) + +//go:embed internal/engine/web/dashboard.html +var mainDashboardHTML []byte + +// handleDashboardPage serves the embedded web dashboard. +func (s *serveServer) handleDashboardPage(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(mainDashboardHTML) +} diff --git a/service_crd.go b/service_crd.go index 6292b4b..4548df0 100644 --- a/service_crd.go +++ b/service_crd.go @@ -35,7 +35,7 @@ type ServiceCRDMeta struct { // ServiceCRDSpec defines the service specification. type ServiceCRDSpec struct { - Image string `yaml:"image"` + Image string `yaml:"image,omitempty"` Platform string `yaml:"platform,omitempty"` Ports []ServicePort `yaml:"ports,omitempty"` Env []string `yaml:"env,omitempty"` @@ -46,6 +46,20 @@ type ServiceCRDSpec struct { Tools []ServiceTool `yaml:"tools,omitempty"` Modalities []string `yaml:"modalities,omitempty"` // e.g. ["voice"] Bus ServiceBus `yaml:"bus,omitempty"` + Local *ServiceLocal `yaml:"local,omitempty"` +} + +// ServiceLocal describes bare-metal execution for a service, used when the +// container runtime is unavailable or when the service must run natively +// (e.g. Apple Silicon workloads without an OCI image). Supervision is handled +// by the reconcile loop: the service is restarted on the next cycle if it +// exits. No per-service watcher goroutines. +type ServiceLocal struct { + Command string `yaml:"command"` // Executable name or absolute path + Args []string `yaml:"args,omitempty"` + Workdir string `yaml:"workdir,omitempty"` // Absolute or workspace-relative + Venv string `yaml:"venv,omitempty"` // Python venv whose bin/ is prepended to PATH + Env map[string]string `yaml:"env,omitempty"` // KEY: value map appended to process env } // ServicePort maps a container port to a host port. @@ -157,8 +171,11 @@ func LoadServiceCRD(root, name string) (*ServiceCRD, error) { name, crd.Metadata.Name) } - if crd.Spec.Image == "" { - return nil, fmt.Errorf("service CRD %q: spec.image is required", name) + if crd.Spec.Image == "" && crd.Spec.Local == nil { + return nil, fmt.Errorf("service CRD %q: spec.image or spec.local is required", name) + } + if crd.Spec.Local != nil && crd.Spec.Local.Command == "" { + return nil, fmt.Errorf("service CRD %q: spec.local.command is required when spec.local is set", name) } // Apply defaults diff --git a/service_provider.go b/service_provider.go index 0ab1b77..3318d92 100644 --- a/service_provider.go +++ b/service_provider.go @@ -50,26 +50,52 @@ func (s *ServiceProvider) LoadConfig(root string) (any, error) { // ─── FetchLive ────────────────────────────────────────────────────────────────── -// ServiceLiveState holds the runtime state of managed containers. +// ServiceLiveState holds the runtime state of managed services across both +// execution modes. `Available` reports whether the container runtime is up; +// local-mode reconciliation works regardless. type ServiceLiveState struct { - Available bool + Available bool // container runtime reachable Containers map[string]*ContainerInfo // keyed by service name + LocalProcs map[string]*LocalProcess // keyed by service name } -// FetchLive queries the Docker daemon for all cogos-managed containers. +// FetchLive queries both the Docker daemon and local PID files for the +// current live state of all services. func (s *ServiceProvider) FetchLive(ctx context.Context, config any) (any, error) { s.mu.Lock() if s.runtime == nil { s.runtime = NewDockerClient("") } + root := s.root s.mu.Unlock() live := &ServiceLiveState{ Containers: make(map[string]*ContainerInfo), + LocalProcs: make(map[string]*LocalProcess), + } + + // Build a name→CRD lookup so ListLocalProcessesWithCRDs can adopt legacy + // PID files whose argv matches the current CRD's expected command. The + // reconcile harness passes the LoadConfig output through as `config`; + // when it's the expected shape we forward it, otherwise we fall back to + // the strict (non-adopting) scan. + var crdMap map[string]*ServiceCRD + if crds, ok := config.([]ServiceCRD); ok { + crdMap = make(map[string]*ServiceCRD, len(crds)) + for i := range crds { + crdMap[crds[i].Metadata.Name] = &crds[i] + } + } + + // Local processes are discoverable whether or not Docker is up. + if procs, err := ListLocalProcessesWithCRDs(root, crdMap); err == nil { + live.LocalProcs = procs + } else { + log.Printf("[service] warning: list local processes: %v", err) } if err := s.runtime.Ping(ctx); err != nil { - // Runtime not available — return empty live state + // Runtime not available — return with locals populated but no containers. live.Available = false return live, nil } @@ -96,9 +122,33 @@ func (s *ServiceProvider) FetchLive(ctx context.Context, config any) (any, error return live, nil } +// ─── Execution-mode selection ─────────────────────────────────────────────────── + +// serviceMode returns the effective execution mode for a CRD given current +// runtime availability. Docker is preferred when both an image is declared +// and the runtime is reachable; local is the fallback. "none" indicates the +// CRD cannot be executed in the current environment. +const ( + modeDocker = "docker" + modeLocal = "local" + modeNone = "none" +) + +func serviceMode(crd *ServiceCRD, runtimeAvailable bool) string { + if crd.Spec.Image != "" && runtimeAvailable { + return modeDocker + } + if crd.Spec.Local != nil { + return modeLocal + } + return modeNone +} + // ─── ComputePlan ──────────────────────────────────────────────────────────────── -// ComputePlan compares declared CRDs against live container state. +// ComputePlan compares declared CRDs against live state (containers + local +// processes) and produces per-service create/update/skip/delete actions. +// Each CRD resolves to exactly one mode; orphans in either mode get cleaned up. func (s *ServiceProvider) ComputePlan(config any, live any, state *ReconcileState) (*ReconcilePlan, error) { crds := config.([]ServiceCRD) liveState := live.(*ServiceLiveState) @@ -110,17 +160,7 @@ func (s *ServiceProvider) ComputePlan(config any, live any, state *ReconcileStat } if !liveState.Available { - plan.Warnings = append(plan.Warnings, "container runtime not available — skipping all services") - for _, crd := range crds { - plan.Actions = append(plan.Actions, ReconcileAction{ - Action: ActionSkip, - ResourceType: "service", - Name: crd.Metadata.Name, - Details: map[string]any{"reason": "runtime not available"}, - }) - plan.Summary.Skipped++ - } - return plan, nil + plan.Warnings = append(plan.Warnings, "container runtime not available — docker-mode services will be skipped") } // Sort CRDs by name for deterministic output @@ -128,67 +168,61 @@ func (s *ServiceProvider) ComputePlan(config any, live any, state *ReconcileStat return crds[i].Metadata.Name < crds[j].Metadata.Name }) - seen := make(map[string]bool) + seenContainer := make(map[string]bool) + seenLocal := make(map[string]bool) for _, crd := range crds { name := crd.Metadata.Name - seen[name] = true - info, exists := liveState.Containers[name] - - if !exists { - // Declared but no container + mode := serviceMode(&crd, liveState.Available) + + switch mode { + case modeDocker: + seenContainer[name] = true + s.planDockerService(plan, &crd, liveState.Containers[name]) + case modeLocal: + seenLocal[name] = true + s.planLocalService(plan, &crd, liveState.LocalProcs[name]) + case modeNone: plan.Actions = append(plan.Actions, ReconcileAction{ - Action: ActionCreate, + Action: ActionSkip, ResourceType: "service", Name: name, - Details: map[string]any{ - "reason": "no container found", - "image": crd.Spec.Image, - }, + Details: map[string]any{"reason": "no image and no local spec (or runtime unavailable)"}, }) - plan.Summary.Creates++ - continue + plan.Summary.Skipped++ } + } - // Container exists — check for drift - drifted, reasons := detectServiceDrift(&crd, info) - if drifted { - plan.Actions = append(plan.Actions, ReconcileAction{ - Action: ActionUpdate, - ResourceType: "service", - Name: name, - Details: map[string]any{ - "reason": "drift detected: " + joinReasons(reasons), - "container_id": info.ID[:12], - }, - }) - plan.Summary.Updates++ - } else { + // Orphaned managed containers (not declared by any CRD as docker-mode). + for name, info := range liveState.Containers { + if !seenContainer[name] { plan.Actions = append(plan.Actions, ReconcileAction{ - Action: ActionSkip, + Action: ActionDelete, ResourceType: "service", Name: name, Details: map[string]any{ - "reason": "in sync", + "reason": "no matching service CRD (docker)", "container_id": info.ID[:12], - "status": info.State.Status, + "mode": modeDocker, }, }) - plan.Summary.Skipped++ + plan.Summary.Deletes++ } } - // Check for orphaned managed containers (not in any CRD) - for name := range liveState.Containers { - if !seen[name] { - info := liveState.Containers[name] + // Orphaned local processes — includes "declared CRD is docker-mode but + // a local process is running under the same name" (stale from a prior + // mode), which we want to stop. + for name, proc := range liveState.LocalProcs { + if !seenLocal[name] { plan.Actions = append(plan.Actions, ReconcileAction{ Action: ActionDelete, ResourceType: "service", Name: name, Details: map[string]any{ - "reason": "no matching service CRD", - "container_id": info.ID[:12], + "reason": "no matching service CRD (local)", + "pid": proc.PID, + "mode": modeLocal, }, }) plan.Summary.Deletes++ @@ -198,6 +232,97 @@ func (s *ServiceProvider) ComputePlan(config any, live any, state *ReconcileStat return plan, nil } +// planDockerService produces the action for a docker-mode CRD. +func (s *ServiceProvider) planDockerService(plan *ReconcilePlan, crd *ServiceCRD, info *ContainerInfo) { + name := crd.Metadata.Name + if info == nil { + plan.Actions = append(plan.Actions, ReconcileAction{ + Action: ActionCreate, + ResourceType: "service", + Name: name, + Details: map[string]any{ + "reason": "no container found", + "image": crd.Spec.Image, + "mode": modeDocker, + }, + }) + plan.Summary.Creates++ + return + } + drifted, reasons := detectServiceDrift(crd, info) + if drifted { + plan.Actions = append(plan.Actions, ReconcileAction{ + Action: ActionUpdate, + ResourceType: "service", + Name: name, + Details: map[string]any{ + "reason": "drift detected: " + joinReasons(reasons), + "container_id": info.ID[:12], + "mode": modeDocker, + }, + }) + plan.Summary.Updates++ + return + } + plan.Actions = append(plan.Actions, ReconcileAction{ + Action: ActionSkip, + ResourceType: "service", + Name: name, + Details: map[string]any{ + "reason": "in sync", + "container_id": info.ID[:12], + "status": info.State.Status, + "mode": modeDocker, + }, + }) + plan.Summary.Skipped++ +} + +// planLocalService produces the action for a local-mode CRD. +func (s *ServiceProvider) planLocalService(plan *ReconcilePlan, crd *ServiceCRD, proc *LocalProcess) { + name := crd.Metadata.Name + if proc == nil || !proc.Running { + plan.Actions = append(plan.Actions, ReconcileAction{ + Action: ActionCreate, + ResourceType: "service", + Name: name, + Details: map[string]any{ + "reason": "no local process running", + "mode": modeLocal, + }, + }) + plan.Summary.Creates++ + return + } + // Drift: compare cmdHash of declared spec vs recorded spec. + expected := localCmdHash(crd.Spec.Local, resolveWorkdir(s.root, crd.Spec.Local)) + if expected != proc.CmdHash { + plan.Actions = append(plan.Actions, ReconcileAction{ + Action: ActionUpdate, + ResourceType: "service", + Name: name, + Details: map[string]any{ + "reason": "declared spec differs from running cmd_hash", + "pid": proc.PID, + "mode": modeLocal, + }, + }) + plan.Summary.Updates++ + return + } + plan.Actions = append(plan.Actions, ReconcileAction{ + Action: ActionSkip, + ResourceType: "service", + Name: name, + Details: map[string]any{ + "reason": "in sync", + "pid": proc.PID, + "mode": modeLocal, + }, + }) + plan.Summary.Skipped++ +} + // detectServiceDrift compares a CRD against a live container. func detectServiceDrift(crd *ServiceCRD, info *ContainerInfo) (bool, []string) { var reasons []string @@ -259,12 +384,15 @@ func detectServiceDrift(crd *ServiceCRD, info *ContainerInfo) (bool, []string) { // ─── ApplyPlan ────────────────────────────────────────────────────────────────── -// ApplyPlan executes planned service changes. +// ApplyPlan executes planned service changes, dispatching per action to the +// docker or local handler based on the `mode` detail key attached by the +// planner. func (s *ServiceProvider) ApplyPlan(ctx context.Context, plan *ReconcilePlan) ([]ReconcileResult, error) { s.mu.Lock() if s.runtime == nil { s.runtime = NewDockerClient("") } + root := s.root s.mu.Unlock() var results []ReconcileResult @@ -272,22 +400,86 @@ func (s *ServiceProvider) ApplyPlan(ctx context.Context, plan *ReconcilePlan) ([ if action.Action == ActionSkip { continue } + mode, _ := action.Details["mode"].(string) + if mode == "" { + mode = modeDocker // backward-compat default + } - switch action.Action { - case ActionCreate: - result := s.applyCreate(ctx, action.Name) - results = append(results, result) - case ActionUpdate: - result := s.applyUpdate(ctx, action.Name) - results = append(results, result) - case ActionDelete: - result := s.applyDelete(ctx, action.Name) - results = append(results, result) + switch mode { + case modeLocal: + results = append(results, s.applyLocal(root, action)) + default: + results = append(results, s.applyDocker(ctx, action)) } } return results, nil } +// applyDocker dispatches create/update/delete for docker-mode services. +func (s *ServiceProvider) applyDocker(ctx context.Context, action ReconcileAction) ReconcileResult { + switch action.Action { + case ActionCreate: + return s.applyCreate(ctx, action.Name) + case ActionUpdate: + return s.applyUpdate(ctx, action.Name) + case ActionDelete: + return s.applyDelete(ctx, action.Name) + } + return ReconcileResult{Phase: "service", Action: string(action.Action), Name: action.Name, Status: ApplySkipped} +} + +// applyLocal dispatches create/update/delete for local-mode services. +func (s *ServiceProvider) applyLocal(root string, action ReconcileAction) ReconcileResult { + switch action.Action { + case ActionCreate: + return s.applyLocalCreate(root, action.Name) + case ActionUpdate: + // Stop then recreate; cheapest way to pick up new argv/env. + if err := LocalStop(root, action.Name); err != nil { + return ReconcileResult{ + Phase: "service", Action: "update", Name: action.Name, + Status: ApplyFailed, Error: fmt.Sprintf("stop before update: %v", err), + } + } + result := s.applyLocalCreate(root, action.Name) + result.Action = "update" + return result + case ActionDelete: + if err := LocalStop(root, action.Name); err != nil { + return ReconcileResult{ + Phase: "service", Action: "delete", Name: action.Name, + Status: ApplyFailed, Error: err.Error(), + } + } + return ReconcileResult{ + Phase: "service", Action: "delete", Name: action.Name, + Status: ApplySucceeded, + } + } + return ReconcileResult{Phase: "service", Action: string(action.Action), Name: action.Name, Status: ApplySkipped} +} + +func (s *ServiceProvider) applyLocalCreate(root, name string) ReconcileResult { + crd, err := LoadServiceCRD(root, name) + if err != nil { + return ReconcileResult{ + Phase: "service", Action: "create", Name: name, + Status: ApplyFailed, Error: err.Error(), + } + } + proc, err := LocalStart(root, crd) + if err != nil { + return ReconcileResult{ + Phase: "service", Action: "create", Name: name, + Status: ApplyFailed, Error: err.Error(), + } + } + return ReconcileResult{ + Phase: "service", Action: "create", Name: name, + Status: ApplySucceeded, CreatedID: fmt.Sprintf("pid:%d", proc.PID), + } +} + func (s *ServiceProvider) applyCreate(ctx context.Context, name string) ReconcileResult { crd, err := LoadServiceCRD(s.root, name) if err != nil { @@ -434,6 +626,28 @@ func (s *ServiceProvider) BuildState(config any, live any, existing *ReconcileSt "status": info.State.Status, "running": info.State.Running, "health": health, + "mode": modeDocker, + }, + } + state.Resources = append(state.Resources, resource) + } + + for name, proc := range liveState.LocalProcs { + resource := ReconcileResource{ + Address: "service." + name, + Type: "local", + Mode: ModeManaged, + ExternalID: fmt.Sprintf("pid:%d", proc.PID), + Name: name, + LastRefreshed: nowISO(), + Attributes: map[string]any{ + "pid": proc.PID, + "started_at": proc.StartedAt, + "workdir": proc.Workdir, + "cmd_hash": proc.CmdHash, + "running": proc.Running, + "log_path": proc.LogPath, + "mode": modeLocal, }, } state.Resources = append(state.Resources, resource) @@ -473,18 +687,22 @@ func (s *ServiceProvider) Health() ResourceStatus { runtime := NewDockerClient("") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - - if err := runtime.Ping(ctx); err != nil { - return ResourceStatus{ - Sync: SyncStatusUnknown, Health: HealthMissing, Operation: OperationIdle, - Message: "container runtime not available", - } - } + runtimeUp := runtime.Ping(ctx) == nil down := 0 for _, crd := range crds { - entry, _ := runtime.FindManagedContainer(ctx, crd.Metadata.Name) - if entry == nil || entry.State != "running" { + switch serviceMode(&crd, runtimeUp) { + case modeDocker: + entry, _ := runtime.FindManagedContainer(ctx, crd.Metadata.Name) + if entry == nil || entry.State != "running" { + down++ + } + case modeLocal: + proc, _ := LocalStatus(root, crd.Metadata.Name) + if proc == nil || !proc.Running { + down++ + } + case modeNone: down++ } } @@ -575,25 +793,30 @@ func (m *ServiceHealthMonitor) checkAndEmit() { return } - // Use a short context just for the ping check pingCtx, pingCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pingCancel() - - if err := m.runtime.Ping(pingCtx); err != nil { - return - } + runtimeUp := m.runtime.Ping(pingCtx) == nil for _, crd := range crds { - // Per-service timeout so one slow service doesn't starve the rest svcCtx, svcCancel := context.WithTimeout(context.Background(), 10*time.Second) - entry, _ := m.runtime.FindManagedContainer(svcCtx, crd.Metadata.Name) status := "not_running" health := "unknown" - - if entry != nil { - status = entry.State - if entry.State == "running" { + mode := serviceMode(&crd, runtimeUp) + + switch mode { + case modeDocker: + entry, _ := m.runtime.FindManagedContainer(svcCtx, crd.Metadata.Name) + if entry != nil { + status = entry.State + if entry.State == "running" { + health = checkServiceHealth(svcCtx, &crd) + } + } + case modeLocal: + proc, _ := LocalStatus(m.root, crd.Metadata.Name) + if proc != nil && proc.Running { + status = "running" health = checkServiceHealth(svcCtx, &crd) } } @@ -605,6 +828,7 @@ func (m *ServiceHealthMonitor) checkAndEmit() { "status": status, "health": health, "image": crd.Spec.Image, + "mode": mode, } if _, err := m.mgr.appendBusEvent(serviceHealthBusID, BlockServiceHealth, "kernel:cogos", payload); err != nil { log.Printf("[svc-health] emit event for %s: %v", crd.Metadata.Name, err) diff --git a/trace/events.go b/trace/events.go new file mode 100644 index 0000000..c0d1684 --- /dev/null +++ b/trace/events.go @@ -0,0 +1,135 @@ +// Package trace defines the cycle-trace event schema emitted by the cogos +// kernel's homeostatic metabolic cycle onto the kernel bus +// (http://localhost:6931/v1/bus) for external consumers such as the Mod³ +// dashboard. +// +// This package only declares the wire types and constructor helpers. Wiring +// these events into the state-transition path (internal/engine/process.go) +// and the tool-dispatch/assessment paths (agent_harness.go, agent_tools*.go) +// is performed in a later wave — see +// .cog/mem/semantic/plans/audio-loop-and-trace-wiring.cog.md (task B5). +// +// Design notes: +// - The package deliberately has no dependency on cogos-internal packages +// (engine, harness, bus) so that both the engine and the root cogos +// package can import it without creating an import cycle. +// - State values are accepted as fmt.Stringer so callers can pass +// engine.ProcessState (an int enum with a String() method) directly. +// - The envelope is transport-agnostic. The actual bus-publish call is +// owned by task D2 / B5 and is out of scope here. +package trace + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" +) + +// Kind identifies the payload variant carried by a CycleEvent. +type Kind string + +const ( + KindStateTransition Kind = "state_transition" + KindToolDispatch Kind = "tool_dispatch" + KindAssessment Kind = "assessment" +) + +// CycleEvent is the common envelope for cycle-trace events emitted onto the +// kernel bus. All events within a single metabolic-cycle iteration share a +// CycleID so consumers can correlate them. +type CycleEvent struct { + ID string `json:"id"` + Timestamp time.Time `json:"ts"` + Source string `json:"source"` // identity name (e.g. "cog") + CycleID string `json:"cycle_id"` // correlates events within one metabolic-cycle iteration + Kind Kind `json:"kind"` + Payload json.RawMessage `json:"payload"` +} + +// StateTransitionEvent describes a move between process states +// (engine.StateActive / StateReceptive / StateConsolidating / StateDormant). +type StateTransitionEvent struct { + From string `json:"from"` + To string `json:"to"` + Reason string `json:"reason,omitempty"` +} + +// ToolDispatchEvent describes a single tool invocation issued by the agent +// harness (see agent_tools*.go). +type ToolDispatchEvent struct { + Tool string `json:"tool"` + Args json.RawMessage `json:"args,omitempty"` + DurationMS int64 `json:"duration_ms"` + Error string `json:"error,omitempty"` +} + +// AssessmentEvent mirrors the Assessment struct in agent_harness.go. The +// Action field uses the vocabulary the harness already emits: +// "observe" | "propose" | "execute" | "sleep" | "wait" (and synonyms such as +// "consolidate" | "repair" | "escalate"). +type AssessmentEvent struct { + Action string `json:"action"` + Confidence float64 `json:"confidence"` + Rationale string `json:"rationale,omitempty"` +} + +// NewStateTransition builds a CycleEvent wrapping a StateTransitionEvent. +// from and to accept any fmt.Stringer so that engine.ProcessState values can +// be passed directly without introducing an import dependency. +func NewStateTransition(source, cycleID string, from, to fmt.Stringer, reason string) (CycleEvent, error) { + fromStr, toStr := "", "" + if from != nil { + fromStr = from.String() + } + if to != nil { + toStr = to.String() + } + return newEvent(source, cycleID, KindStateTransition, StateTransitionEvent{ + From: fromStr, + To: toStr, + Reason: reason, + }) +} + +// NewToolDispatch builds a CycleEvent wrapping a ToolDispatchEvent. args may +// be nil; if non-nil it is expected to already be valid JSON. +func NewToolDispatch(source, cycleID, tool string, args json.RawMessage, duration time.Duration, err error) (CycleEvent, error) { + errStr := "" + if err != nil { + errStr = err.Error() + } + return newEvent(source, cycleID, KindToolDispatch, ToolDispatchEvent{ + Tool: tool, + Args: args, + DurationMS: duration.Milliseconds(), + Error: errStr, + }) +} + +// NewAssessment builds a CycleEvent wrapping an AssessmentEvent. +func NewAssessment(source, cycleID, action string, confidence float64, rationale string) (CycleEvent, error) { + return newEvent(source, cycleID, KindAssessment, AssessmentEvent{ + Action: action, + Confidence: confidence, + Rationale: rationale, + }) +} + +// newEvent marshals the given payload and wraps it in a CycleEvent with a +// freshly minted UUID v4 and the current wall-clock time. +func newEvent(source, cycleID string, kind Kind, payload any) (CycleEvent, error) { + raw, err := json.Marshal(payload) + if err != nil { + return CycleEvent{}, fmt.Errorf("trace: marshal %s payload: %w", kind, err) + } + return CycleEvent{ + ID: uuid.NewString(), + Timestamp: time.Now().UTC(), + Source: source, + CycleID: cycleID, + Kind: kind, + Payload: raw, + }, nil +} diff --git a/trace_emit.go b/trace_emit.go new file mode 100644 index 0000000..6b47468 --- /dev/null +++ b/trace_emit.go @@ -0,0 +1,132 @@ +// trace_emit.go — Bus-publish helper for cycle-trace events (ADR-083). +// +// This file is the bridge between the schema-only `trace` subpackage and the +// kernel's bus API (`busSessionManager.appendBusEvent`). Emission is +// best-effort and non-blocking: errors are logged, never propagated. Losing +// a trace event is always preferable to stalling the metabolic cycle. +// +// Wiring: +// - At serve startup, call `InstallTraceEmitter(busManager, root)` once. +// That sets: +// * trace bus ensured on disk (`bus_cycle_trace`) +// * `engine.TraceEmitter` hook wired to `emitCycleEvent` +// * `engine.TraceIdentity` wired to the active identity name +// After that, engine/process.go and the agent harness can call +// `emitCycleEvent` (or the engine hook) without further setup. +package main + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "sync/atomic" + + "github.com/cogos-dev/cogos/internal/engine" + "github.com/cogos-dev/cogos/trace" +) + +// traceBusID is the dedicated bus that carries cycle-trace events (B5 / +// ADR-083). External consumers (e.g. Mod³ dashboard) subscribe via the +// existing /v1/events/stream SSE endpoint filtered on this bus. +const traceBusID = "bus_cycle_trace" + +// traceBusMgr holds the bus manager used by emitCycleEvent. Set once by +// InstallTraceEmitter. Accessed without a lock — a single writer at startup. +var traceBusMgr atomic.Pointer[busSessionManager] + +// traceIdentityName is the cached active-identity name used as the `source` +// field on emitted events. Falls back to "cog" until wired. +var traceIdentityName atomic.Value // string + +func init() { + traceIdentityName.Store("cog") +} + +// InstallTraceEmitter wires the engine's TraceEmitter / TraceIdentity hooks +// to the kernel bus and the active identity. Safe to call once at daemon +// start; subsequent calls overwrite the bus manager. +// +// root is the workspace root (used to resolve the identity name). +func InstallTraceEmitter(mgr *busSessionManager, root string) { + if mgr == nil { + return + } + traceBusMgr.Store(mgr) + ensureTraceBus(mgr) + + if name, err := GetIdentityName(root); err == nil && name != "" { + traceIdentityName.Store(name) + } + // TODO: wire to active identity (re-fetch if identity switches at runtime). + + engine.TraceEmitter = emitCycleEvent + engine.TraceIdentity = func() string { + if v := traceIdentityName.Load(); v != nil { + if s, ok := v.(string); ok && s != "" { + return s + } + } + return "cog" + } + log.Printf("[trace] cycle-trace emitter installed (bus=%s identity=%s)", + traceBusID, engine.TraceIdentity()) +} + +// ensureTraceBus creates the trace bus directory, events file, and registry +// entry if they don't already exist. +func ensureTraceBus(mgr *busSessionManager) { + busDir := filepath.Join(mgr.busesDir(), traceBusID) + if err := os.MkdirAll(busDir, 0o755); err != nil { + log.Printf("[trace] create bus dir: %v", err) + return + } + eventsFile := filepath.Join(busDir, "events.jsonl") + if _, err := os.Stat(eventsFile); os.IsNotExist(err) { + if f, err := os.Create(eventsFile); err == nil { + f.Close() + } + } + if err := mgr.registerBus(traceBusID, "kernel:trace", "kernel:trace"); err != nil { + log.Printf("[trace] register bus: %v", err) + } +} + +// emitCycleEvent publishes a CycleEvent onto the trace bus. Best-effort: +// marshal or append errors are logged, never returned. The publish runs on a +// goroutine so the caller (state machine, harness loop) is never blocked by +// bus I/O or handler dispatch. +func emitCycleEvent(ev trace.CycleEvent) { + mgr := traceBusMgr.Load() + if mgr == nil { + return + } + // Marshal via the trace package's canonical JSON shape, then re-decode + // into a map so appendBusEvent can carry it as a CogBlock payload. + raw, err := json.Marshal(ev) + if err != nil { + log.Printf("[trace] marshal: %v", err) + return + } + var payload map[string]interface{} + if err := json.Unmarshal(raw, &payload); err != nil { + log.Printf("[trace] unmarshal: %v", err) + return + } + eventType := "cycle." + string(ev.Kind) + go func() { + if _, err := mgr.appendBusEvent(traceBusID, eventType, engine.TraceIdentity(), payload); err != nil { + log.Printf("[trace] append: %v", err) + } + }() +} + +// --- cycleID context plumbing for the agent harness --------------------------- +// +// The harness's Assess/Execute signatures are public; threading a cycleID +// parameter through them would be a breaking change. Instead we stash the ID +// on the context so tool-dispatch sites and per-turn emission can pull it +// out without plumbing. A missing value yields "" and emission falls back to +// a one-shot ID. See agent_harness.go / agent_serve.go for the real helpers. + +type cycleIDKey struct{}