Compare commits
22 Commits
39e9227fdb
...
chore/vend
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cd4cf98c3 | |||
| 38c472a118 | |||
| 1d0197da69 | |||
| 0920c4cb6d | |||
| 2254a07baf | |||
| 1b9c761274 | |||
| a126cfa710 | |||
| 86fa57b5cd | |||
| 8e97d589af | |||
| 0dd8c28815 | |||
| 4e294b3630 | |||
| 85f0bb67fa | |||
| c3f478b8f8 | |||
| 1aaedf498a | |||
| 20adb89650 | |||
| 058f1afcdc | |||
| 2b73290994 | |||
| 428f4fe945 | |||
| 195d45a0e9 | |||
| 5b68bdc16c | |||
| 757ef2f34b | |||
| b27e1bf3ec |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,3 +24,7 @@ cmd/desktop/build/
|
||||
cmd/desktop/Oikos
|
||||
desktop
|
||||
/eval
|
||||
|
||||
# Local tooling artifacts (Playwright MCP session logs, stray screenshots)
|
||||
.playwright-mcp/
|
||||
config-screen.png
|
||||
|
||||
104
archive/knowledge/infrastructure/oikos-check-lifecycle.md
Normal file
104
archive/knowledge/infrastructure/oikos-check-lifecycle.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Oikos check lifecycle — how monitoring works
|
||||
|
||||
This runbook covers how Oikos health checks are derived, created, and wired so
|
||||
an agent (Nomos) doesn't reverse-engineer source when asked to add monitoring to
|
||||
an entity — the problem that stranded session `23da10db` (2026-08-03).
|
||||
|
||||
## Concepts
|
||||
|
||||
- **`check_defs`** (scheduler config, table `check_defs`): the row the scheduler
|
||||
reads to know *what* to probe and *when*. One per check instance.
|
||||
- **`check` entity** (type `check`, slug `check:<kind>:<target>:<n>`): the
|
||||
knowledge-graph entity for that check. It carries attributes
|
||||
(`check_type`, `target`, `port`, …) and `checks` edges to the probed target.
|
||||
- **`monitoring` spec** on an entity type (`entity_types.monitoring_spec`): the
|
||||
default list of check kinds (e.g. `[http, process]` for `service`).
|
||||
- Per-entity override: set `monitoring` in the entity's attributes —
|
||||
`"none"` for zero checks, `["http"]` to replace the type defaults.
|
||||
- **`checkdefaults.Ensure`** (`internal/checkdefaults/defaults.go`): the
|
||||
function that reads the monitoring spec, resolves host/port/URL from
|
||||
attributes + relationships, and writes `check_defs` rows. Idempotent.
|
||||
|
||||
## When checks are derived
|
||||
|
||||
`checkdefaults.Ensure` runs in three situations (as of v0.17.1+):
|
||||
|
||||
1. **Seed/deploy ingest** — `internal/db/seed.go:231`. Every entity gets its
|
||||
default checks once on initial ingest.
|
||||
2. **HTTP `POST /api/v1/entities` (create)** — `ensureDefaultChecks` at
|
||||
`internal/httpapi/impl.go:1012`. Creating an entity via the REST API derives
|
||||
its checks in the same transaction.
|
||||
3. **HTTP `PATCH /api/v1/entities` (patch)** — `ensureDefaultChecks` at
|
||||
`internal/httpapi/impl.go:1280`. Changing an entity's attributes (especially
|
||||
`monitoring`) via the REST API regenerates its checks.
|
||||
4. **MCP `create_entity`** — SAME hook. Creating an entity via the MCP tool
|
||||
derives checks. (Added 2026-08-03; previously MCP had no create.)
|
||||
5. **MCP `update_entity_attributes`** — SAME hook. Changing an entity's
|
||||
`monitoring` attribute via MCP now regenerates checks. (Added 2026-08-03;
|
||||
previously MCP updates silently skipped check derivation — the exact bug
|
||||
that stranded the haos session.)
|
||||
|
||||
## Check slug grammar
|
||||
|
||||
```
|
||||
check:<kind>:<target-type>:<target-name>:<n>
|
||||
```
|
||||
|
||||
Examples: `check:http:service:jellyfin:0`, `check:vm-status:vm:haos:0`,
|
||||
`check:cert-expiry:cert:house.hubris.network:0`.
|
||||
|
||||
## Adding monitoring to an entity
|
||||
|
||||
**If the entity already exists:**
|
||||
|
||||
```
|
||||
update_entity_attributes(slug="service:haos", attributes={"monitoring":["http"]})
|
||||
```
|
||||
|
||||
This regenerates checks via `checkdefaults.Ensure`. The result message tells you
|
||||
how many checks were derived and whether any kinds were skipped (and why).
|
||||
|
||||
**If the entity does not exist yet (a new check, ingress, cert, etc.):**
|
||||
|
||||
```
|
||||
create_entity(type="check", name="HAOS http check",
|
||||
slug="check:http:service:haos:0",
|
||||
attributes={"check_type":"http:service","target":"service:haos","port":"8123"})
|
||||
```
|
||||
|
||||
This creates the entity AND derives its `check_defs`. Same for a new `ingress`
|
||||
(`type=ingress`, monitoring `[http]`) or `cert` (`type=cert`,
|
||||
monitoring `[cert-expiry]`).
|
||||
|
||||
**To remove monitoring:** set `monitoring:["none"]` or transition the entity
|
||||
to a terminal lifecycle state (`set_entity_state` → `deprecated`/`destroyed`).
|
||||
|
||||
## Caveats
|
||||
|
||||
- **A service without a `url` attribute AND without a `probe_unit` gets no
|
||||
process check** (the http check covers liveness; the process check would
|
||||
be redundant without an opt-in `probe_unit`). The skip is logged.
|
||||
- **A service whose address comes from a `hosts` edge** may produce no checks on
|
||||
initial create because the edge doesn't exist yet — the next inventory ingest
|
||||
(or a later `update_entity_attributes` after the edge is created) fills it in.
|
||||
- **A `not found` error from `update_entity_attributes`** means the entity
|
||||
doesn't exist — use `create_entity` instead.
|
||||
- **`check_defs` has target columns** (`target_id`, `target_type`). A check
|
||||
entity needs a `checks` relationship (`create_relationship(source=check:…,
|
||||
target=service:…, type="checks")`) so the scheduler can resolve what to
|
||||
probe. `create_entity` derives the check_def; `create_relationship` links
|
||||
the check entity to its target in the graph.
|
||||
|
||||
## Related files
|
||||
|
||||
- `internal/checkdefaults/defaults.go` — `Ensure`, `Target`, `LogResult`
|
||||
- `internal/httpapi/default_checks.go` — `ensureDefaultChecks` (HTTP hook)
|
||||
- `internal/db/checks.go` — `db.EnsureEntityChecks` (shared hook)
|
||||
- `internal/db/seed.go` — seed-time check derivation
|
||||
- `internal/mcp/tools.go` — `create_entity`, `update_entity_attributes`
|
||||
|
||||
## Revision history
|
||||
|
||||
- **2026-08-03:** Created after session `23da10db` stranded for lack of entity-
|
||||
creation tool and unawareness of check-derivation triggers. Covers the MCP
|
||||
create_entity + update_entity_attributes regen paths added same day.
|
||||
@@ -60,6 +60,10 @@ type agent struct {
|
||||
// gate serializes turns per session (at most one in-flight turn per
|
||||
// sessionID). See turngate.go and plan 2026-08-03 F1.
|
||||
gate *turnGate
|
||||
// queue holds operator messages that arrived while a turn was already
|
||||
// running; they are auto-run when the gate frees (plan 2026-08-03 F2).
|
||||
// See messagequeue.go.
|
||||
queue *messageQueue
|
||||
}
|
||||
|
||||
func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug string) (*agent, error) {
|
||||
@@ -121,6 +125,7 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug
|
||||
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
gate: newTurnGate(),
|
||||
queue: newMessageQueue(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -176,6 +181,11 @@ type agentEvent struct {
|
||||
Data any `json:"data,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Iteration int `json:"iteration,omitempty"`
|
||||
// IsThinking marks text/text_delta events that carry the model's internal
|
||||
// reasoning (text produced before tool calls in the same iteration), as
|
||||
// distinct from the final response text. The frontend renders these as
|
||||
// collapsible thinking blocks separated from the response.
|
||||
IsThinking bool `json:"is_thinking,omitempty"`
|
||||
}
|
||||
|
||||
func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) {
|
||||
@@ -372,7 +382,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
var msg openai.ChatCompletionMessage
|
||||
var acc openai.ChatCompletionAccumulator
|
||||
|
||||
for attempt := 0; attempt <= maxLLMRetries; attempt++ {
|
||||
// Capture token usage from this LLM response for activity logging.
|
||||
// Previously always NULL — every agent_activity row had no token
|
||||
// count. Now each tool call in this iteration gets the same total.
|
||||
totalTokens := 0
|
||||
|
||||
for attempt := 0; attempt <= maxLLMRetries; attempt++ {
|
||||
acc = openai.ChatCompletionAccumulator{}
|
||||
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
|
||||
for stream.Next() {
|
||||
@@ -404,14 +419,19 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
msg = acc.Choices[0].Message
|
||||
finishReason := acc.Choices[0].FinishReason
|
||||
|
||||
// Capture token usage from this iteration.
|
||||
if acc.Usage.TotalTokens > 0 {
|
||||
totalTokens = int(acc.Usage.TotalTokens)
|
||||
}
|
||||
|
||||
if len(msg.ToolCalls) == 0 {
|
||||
if isRefusalOrEmpty(msg.Content) {
|
||||
if attempt < maxLLMRetries {
|
||||
slog.Warn("nomos: empty or refusal response, retrying",
|
||||
"session", sessionID, "iter", i+1, "attempt", attempt+1,
|
||||
"content_len", len(msg.Content), "finish_reason", finishReason)
|
||||
continue
|
||||
}
|
||||
if attempt < maxLLMRetries {
|
||||
slog.Warn("nomos: empty or refusal response, retrying",
|
||||
"session", sessionID, "iter", i+1, "attempt", attempt+1,
|
||||
"content_len", len(msg.Content), "finish_reason", finishReason)
|
||||
continue
|
||||
}
|
||||
// B.4: surface the real error context (finish_reason +
|
||||
// refusal text) instead of a generic "empty response" —
|
||||
// the operator can tell "content_filter — rephrase" from
|
||||
@@ -460,7 +480,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
// led to each step. Emitting it lets the persist layer accumulate
|
||||
// per-iteration reasoning into the row's text field.
|
||||
if strings.TrimSpace(msg.Content) != "" {
|
||||
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
||||
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID, IsThinking: true})
|
||||
}
|
||||
|
||||
slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID)
|
||||
@@ -495,7 +515,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
slog.Warn("nomos: run retry cap hit — refusing dispatch",
|
||||
"target", t, "failures", n, "session", sessionID)
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args,
|
||||
tc.Function.Arguments, directive, 0, false, correlationID)
|
||||
tc.Function.Arguments, directive, 0, false, correlationID, totalTokens)
|
||||
emit(agentEvent{
|
||||
Type: "tool_result",
|
||||
Data: map[string]any{"name": tc.Function.Name, "result": directive, "id": tc.ID, "retry_capped": true},
|
||||
@@ -546,7 +566,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
inputStr := string(inputJSON)
|
||||
|
||||
if callErr != nil {
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID)
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID, totalTokens)
|
||||
|
||||
// Retry cap: dispatch errors (e.g. MCP client timeout)
|
||||
// count toward the cap too. A command that keeps timing
|
||||
@@ -576,7 +596,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID)
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID, totalTokens)
|
||||
|
||||
// Link any execution this tool queued/started back to this
|
||||
// session, so the auto-continuation worker can feed its result
|
||||
|
||||
@@ -222,7 +222,13 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
|
||||
slog.Info("nomos: turn already active, skipping background resume", "session", sessionID)
|
||||
return false
|
||||
}
|
||||
defer a.gate.release(sessionID)
|
||||
// Release the gate, then drain any operator message that was queued while
|
||||
// this background turn ran (plan 2026-08-03 F2). Queued messages are run as
|
||||
// real user turns server-side; resumeSession itself never enqueues.
|
||||
defer func() {
|
||||
a.gate.release(sessionID)
|
||||
safego.Go("nomos:drain:"+sessionID, func() { a.drainQueued(context.Background(), sessionID) })
|
||||
}()
|
||||
|
||||
placeholder, _ := json.Marshal(map[string]any{
|
||||
"role": "assistant",
|
||||
@@ -236,6 +242,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
|
||||
|
||||
var toolCalls []map[string]any
|
||||
var finalText, errText string
|
||||
var finalThinking string
|
||||
|
||||
persist := func() {
|
||||
if msgID == uuid.Nil {
|
||||
@@ -248,6 +255,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"role": "assistant",
|
||||
"text": text,
|
||||
"thinking": finalThinking,
|
||||
"tool_calls": toolCalls,
|
||||
"auto": true, // marks this as an autonomous continuation, not an operator turn
|
||||
})
|
||||
@@ -283,10 +291,14 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
|
||||
}
|
||||
}
|
||||
toolCalls, finalText, errText = nil, "", ""
|
||||
finalThinking = ""
|
||||
// P3: accumulate per-iteration reasoning instead of overwriting
|
||||
// (same fix as main.go's chat handler). Without this, a resumed
|
||||
// turn's intermediate thinking is lost on reload.
|
||||
// (same fix as main.go's chat handler). Without this, a resumed
|
||||
// turn's intermediate thinking is lost on reload.
|
||||
var textParts []string
|
||||
var thinkingParts []string
|
||||
emit := func(ev agentEvent) {
|
||||
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||
if m, ok := ev.Data.(map[string]any); ok {
|
||||
@@ -313,8 +325,13 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
|
||||
}
|
||||
if ev.Type == "text" {
|
||||
if t, ok := ev.Data.(string); ok && t != "" {
|
||||
textParts = append(textParts, t)
|
||||
finalText = strings.Join(textParts, "\n\n")
|
||||
if ev.IsThinking {
|
||||
thinkingParts = append(thinkingParts, t)
|
||||
finalThinking = strings.Join(thinkingParts, "\n\n")
|
||||
} else {
|
||||
textParts = append(textParts, t)
|
||||
finalText = strings.Join(textParts, "\n\n")
|
||||
}
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,147 @@ func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// runChatTurn is the shared core of an operator-initiated turn: insert an
|
||||
// assistant placeholder, run a.chat with incremental persistence (so whatever
|
||||
// happened before an abort is never lost), finalize the row, and derive a
|
||||
// title. It is agnostic to the transport: `sink` receives every agent event
|
||||
// for delivery (SSE for a live handleChat, a no-op for a queued turn that has
|
||||
// no client attached — the frontend learns about those via the poller + the
|
||||
// status-driven "working" signal). The caller MUST already hold the session's
|
||||
// turn-gate permit.
|
||||
func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string, sink func(agentEvent)) {
|
||||
toolCalls := []map[string]any{}
|
||||
// P3: accumulate per-iteration reasoning instead of overwriting with the
|
||||
// final `text` event (see the original inline comment in handleChat).
|
||||
var textParts []string
|
||||
var thinkingParts []string
|
||||
var finalText string
|
||||
var finalThinking string
|
||||
|
||||
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
|
||||
msgID, err := a.store.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
|
||||
if err != nil {
|
||||
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
|
||||
}
|
||||
persist := func() {
|
||||
if msgID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"role": "assistant",
|
||||
"text": finalText,
|
||||
"thinking": finalThinking,
|
||||
"tool_calls": toolCalls,
|
||||
})
|
||||
a.store.updateMessage(pctx, msgID, body)
|
||||
}
|
||||
|
||||
a.chat(ctx, sessionID, message, func(ev agentEvent) {
|
||||
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||
if m, ok := ev.Data.(map[string]any); ok {
|
||||
m["type"] = ev.Type
|
||||
// One entry per tool call: tool_use creates it, tool_result
|
||||
// merges the result into the same entry (matched by id).
|
||||
id, _ := m["id"].(string)
|
||||
if id != "" && ev.Type == "tool_result" {
|
||||
for _, existing := range toolCalls {
|
||||
if eID, _ := existing["id"].(string); eID == id {
|
||||
for k, v := range m {
|
||||
existing[k] = v
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toolCalls = append(toolCalls, m)
|
||||
}
|
||||
}
|
||||
persist() // live: survives even if the client disconnects right after
|
||||
}
|
||||
if ev.Type == "text" {
|
||||
if t, ok := ev.Data.(string); ok && t != "" {
|
||||
if ev.IsThinking {
|
||||
thinkingParts = append(thinkingParts, t)
|
||||
finalThinking = strings.Join(thinkingParts, "\n\n")
|
||||
} else {
|
||||
textParts = append(textParts, t)
|
||||
finalText = strings.Join(textParts, "\n\n")
|
||||
}
|
||||
persist()
|
||||
}
|
||||
}
|
||||
sink(ev)
|
||||
})
|
||||
|
||||
// B.6: if the turn ended with no text and no tool calls (the model
|
||||
// empty-response'd and all retries failed), delete the placeholder row
|
||||
// instead of persisting an empty bubble.
|
||||
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
|
||||
a.store.deleteMessage(pctx, msgID)
|
||||
} else {
|
||||
persist() // final state — same row, updated one last time
|
||||
}
|
||||
|
||||
// Title: prefer the goal once set; else the first assistant answer.
|
||||
if finalText != "" && sessionID != "ephemeral" {
|
||||
var goalTitle string
|
||||
if sess, gerr := a.store.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
|
||||
goalTitle = truncate(sess.Goal, 120)
|
||||
}
|
||||
title := goalTitle
|
||||
if title == "" {
|
||||
title = truncate(finalText, 80)
|
||||
}
|
||||
if title != "" {
|
||||
a.store.updateSessionTitle(pctx, sessionID, title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainAcquireWait is how long drainQueued blocks for a busy gate before
|
||||
// re-queuing and deferring to the holder's own release-drain. A package var so
|
||||
// tests can shorten it; in production it just needs to outlast the brief
|
||||
// release→drain handoff window.
|
||||
var drainAcquireWait = 5 * time.Second
|
||||
|
||||
// drainQueued runs every queued operator message for a session as its own turn,
|
||||
// one at a time, under the turn gate. Called (in a goroutine) whenever a turn
|
||||
// releases the gate — from handleChat (live) and resumeSession (background) —
|
||||
// so a message queued while the agent was busy is acted on as soon as it's
|
||||
// free, without the operator re-sending. See messagequeue.go (plan 2026-08-03
|
||||
// F2).
|
||||
//
|
||||
// Each queued turn is persisted incrementally and has no SSE client (the
|
||||
// browser detached after receiving the `queued` event); the frontend sees the
|
||||
// result via the 3s poller and the status-driven "working" indicator.
|
||||
func (a *agent) drainQueued(ctx context.Context, sessionID string) {
|
||||
for {
|
||||
msg, ok := a.queue.dequeue(sessionID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Block briefly for the gate. If a live turn grabbed it first, put the
|
||||
// message back — that turn's release will drain it again. Never stack.
|
||||
if !a.gate.acquire(sessionID, drainAcquireWait) {
|
||||
a.queue.requeueFront(sessionID, msg)
|
||||
return
|
||||
}
|
||||
slog.Info("nomos: running queued operator message", "session", sessionID)
|
||||
pctx := context.Background()
|
||||
// Run the turn inside a per-iteration closure so the gate release is
|
||||
// deferred to the end of THIS turn (and runs even if runChatTurn
|
||||
// panics — safego recovers the panic at the goroutine boundary, so a
|
||||
// non-deferred release would be skipped and the session's permit held
|
||||
// forever, deadlocking all future turns). A bare `defer release` in
|
||||
// the loop would be wrong too: Go defers run at function exit, not
|
||||
// iteration exit, so the gate would stay held across iterations.
|
||||
func() {
|
||||
defer a.gate.release(sessionID)
|
||||
a.runChatTurn(pctx, ctx, sessionID, msg, func(agentEvent) {})
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
@@ -227,6 +368,17 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
|
||||
w.WriteHeader(200)
|
||||
|
||||
// All writes to w (events + the keepalive comment below) go through one
|
||||
// mutex: http.ResponseWriter is NOT safe for concurrent use, and the
|
||||
// keepalive ticker runs alongside the turn's event sink (plan 2026-08-03
|
||||
// F3). Without this, interleaved writes corrupt the SSE stream.
|
||||
var writeMu sync.Mutex
|
||||
writeEvent := func(ev agentEvent) {
|
||||
writeMu.Lock()
|
||||
defer writeMu.Unlock()
|
||||
sseEvent(w, flusher, ev)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
sessionID := req.SessionID
|
||||
|
||||
@@ -278,134 +430,65 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
st.answerQuestion(pctx, sessionID, qid, req.Message)
|
||||
}
|
||||
|
||||
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
|
||||
writeEvent(agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
|
||||
|
||||
// F1 (plan 2026-08-03): serialize turns per session. The user message is
|
||||
// already persisted above, so even if we can't run this turn right now it
|
||||
// isn't lost. Wait briefly for a finishing background turn (continuation /
|
||||
// resume) so the common case is seamless; if one is still running after
|
||||
// that, tell the operator to retry rather than spawning a second
|
||||
// concurrent turn (the interleaving this gate exists to prevent). On
|
||||
// success the permit is held until this handler returns (stream + post-
|
||||
// processing done); background resumeSession callers skip while it's held.
|
||||
// F1/F2 (plan 2026-08-03): serialize turns per session. The user message is
|
||||
// already persisted above, so it is never lost. Wait briefly for a finishing
|
||||
// background turn; if one is still running after that, QUEUE this message
|
||||
// (don't reject it) and tell the client so it shows a "queued" state. The
|
||||
// in-flight turn's release drains the queue (drainQueued) and runs it as a
|
||||
// real turn server-side. This never stacks concurrent turns — the gate still
|
||||
// guarantees one in-flight turn per session.
|
||||
const turnWait = 5 * time.Second
|
||||
if !a.gate.acquire(sessionID, turnWait) {
|
||||
slog.Info("nomos: turn already active, deferring operator message", "session", sessionID)
|
||||
sseEvent(w, flusher, agentEvent{
|
||||
Type: "error",
|
||||
Data: "Nomos is still finishing a previous step. Your message was saved — give it a moment to finish, then send it again.",
|
||||
})
|
||||
sseEvent(w, flusher, agentEvent{Type: "done", Data: map[string]any{
|
||||
a.queue.enqueue(sessionID, req.Message)
|
||||
slog.Info("nomos: turn already active, queued operator message", "session", sessionID)
|
||||
writeEvent(agentEvent{Type: "queued", Data: sessionID, SessionID: sessionID})
|
||||
writeEvent(agentEvent{Type: "done", Data: map[string]any{
|
||||
"session_id": sessionID,
|
||||
"error": true,
|
||||
"queued": true,
|
||||
}, SessionID: sessionID})
|
||||
return
|
||||
}
|
||||
defer a.gate.release(sessionID)
|
||||
defer func() {
|
||||
a.gate.release(sessionID)
|
||||
// Run any message that was queued while this turn held the gate. In a
|
||||
// goroutine so the HTTP response finishes without waiting on the next
|
||||
// turn; the queued turn has no SSE client of its own.
|
||||
safego.Go("nomos:drain:"+sessionID, func() { a.drainQueued(context.Background(), sessionID) })
|
||||
}()
|
||||
|
||||
toolCalls := []map[string]any{}
|
||||
// P3: accumulate per-iteration reasoning instead of overwriting with
|
||||
// the final `text` event. The agent loop emits a `text` event for each
|
||||
// LLM iteration that produced text (intermediate reasoning before tool
|
||||
// calls + the final answer). Without accumulation, only the last `text`
|
||||
// survives in the persisted row — a reload shows the final summary but
|
||||
// not the thinking that led to each tool call.
|
||||
var textParts []string
|
||||
var finalText string
|
||||
|
||||
// Incremental persistence, mirroring resumeSession's existing
|
||||
// placeholder+update pattern (continue.go): insert a placeholder now,
|
||||
// update the SAME row after every tool call, so whatever happened before
|
||||
// an abort is never lost — only what hadn't happened yet is.
|
||||
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
|
||||
msgID, err := st.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
|
||||
if err != nil {
|
||||
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
|
||||
}
|
||||
persist := func() {
|
||||
if msgID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"role": "assistant",
|
||||
"text": finalText,
|
||||
"tool_calls": toolCalls,
|
||||
})
|
||||
st.updateMessage(pctx, msgID, body)
|
||||
}
|
||||
|
||||
a.chat(ctx, sessionID, req.Message, func(ev agentEvent) {
|
||||
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||
if m, ok := ev.Data.(map[string]any); ok {
|
||||
m["type"] = ev.Type
|
||||
// One entry per tool call: tool_use creates it, tool_result
|
||||
// merges the result into the same entry (matched by id).
|
||||
// Before this fix, both events appended separate entries,
|
||||
// doubling every tool call in the persisted transcript
|
||||
// (confirmed pre-existing in d9cdcee1, v0.3.x era).
|
||||
id, _ := m["id"].(string)
|
||||
if id != "" && ev.Type == "tool_result" {
|
||||
for _, existing := range toolCalls {
|
||||
if eID, _ := existing["id"].(string); eID == id {
|
||||
for k, v := range m {
|
||||
existing[k] = v
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toolCalls = append(toolCalls, m)
|
||||
}
|
||||
}
|
||||
persist() // live: survives even if the client disconnects right after
|
||||
}
|
||||
if ev.Type == "text" {
|
||||
// P3: accumulate. Each `text` event is one iteration's reasoning
|
||||
// (or the final answer). Join with newlines so the persisted row
|
||||
// reads as the full transcript of what the agent said, not just
|
||||
// the last thing.
|
||||
if t, ok := ev.Data.(string); ok && t != "" {
|
||||
textParts = append(textParts, t)
|
||||
finalText = strings.Join(textParts, "\n\n")
|
||||
persist()
|
||||
// F3 (plan 2026-08-03): keep the SSE alive during long turns. A turn can
|
||||
// run for many minutes (provisioning chains, deep research); the model
|
||||
// often takes 20-40s between tool iterations, and with nothing flushed in
|
||||
// that gap a proxy/browser idle timeout silently closes the stream. The
|
||||
// client then sees streaming=false while the server keeps working — the
|
||||
// "I can't tell it's working" desync. An SSE comment line (":keepalive") is
|
||||
// ignored by EventSource but resets idle timers.
|
||||
keepDone := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTicker(12 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-keepDone:
|
||||
return
|
||||
case <-t.C:
|
||||
writeMu.Lock()
|
||||
fmt.Fprintf(w, ":keepalive\n\n")
|
||||
flusher.Flush()
|
||||
writeMu.Unlock()
|
||||
}
|
||||
}
|
||||
sseEvent(w, flusher, ev)
|
||||
}()
|
||||
// Defer the close (not a statement after runChatTurn) so the goroutine
|
||||
// exits even if runChatTurn panics — net/http recovers handler panics, so
|
||||
// a non-deferred close would be skipped and the ticker would keep writing
|
||||
// to a dead ResponseWriter forever.
|
||||
defer close(keepDone)
|
||||
a.runChatTurn(pctx, ctx, sessionID, req.Message, func(ev agentEvent) {
|
||||
writeEvent(ev)
|
||||
})
|
||||
|
||||
// B.6: if the turn ended with no text and no tool calls (the model
|
||||
// empty-response'd and all retries failed), delete the placeholder row
|
||||
// instead of persisting an empty bubble. The error event was already
|
||||
// streamed to the frontend via the 'done with error=true' event, so the
|
||||
// operator sees the error inline — an empty assistant bubble in the
|
||||
// transcript adds nothing and looks like the agent is broken.
|
||||
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
|
||||
st.deleteMessage(pctx, msgID)
|
||||
} else {
|
||||
persist() // final state — same row, updated one last time with the concluding text
|
||||
}
|
||||
|
||||
// Generate a meaningful title from the assistant's first answer
|
||||
// instead of reusing the raw user message for every session.
|
||||
// P2.9 (2026-07-20): prefer the goal as the title when one is set —
|
||||
// the first assistant text is often a greeting or narrative that
|
||||
// doesn't describe the task ("Hey! 👋 Nomos here, running on
|
||||
// mac-mini:8092..."). The goal is the operator's actual intent.
|
||||
// Sessions that never call set_goal (pure Q&A) fall back to the
|
||||
// assistant text, which is still better than the raw user message.
|
||||
if finalText != "" && sessionID != "ephemeral" {
|
||||
var goalTitle string
|
||||
if sess, gerr := st.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
|
||||
goalTitle = truncate(sess.Goal, 120)
|
||||
}
|
||||
title := goalTitle
|
||||
if title == "" {
|
||||
title = truncate(finalText, 80)
|
||||
}
|
||||
if title != "" {
|
||||
st.updateSessionTitle(pctx, sessionID, title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
|
||||
@@ -735,7 +818,7 @@ func newMCPClient(baseURL, token string) (*mcpClient, error) {
|
||||
c := &mcpClient{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
http: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
|
||||
resp, err := c.doRequest("initialize", map[string]any{
|
||||
|
||||
82
cmd/nomos/messagequeue.go
Normal file
82
cmd/nomos/messagequeue.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// maxQueuedPerSession caps a session's queue. A held turn plus unbounded
|
||||
// enqueues would grow memory without limit; an operator nudging a long
|
||||
// autonomous turn realistically queues only a handful, so a generous cap is
|
||||
// pure insurance. Overflow drops the newest enqueue and logs (the message is
|
||||
// already persisted in the DB by handleChat before enqueue, so it isn't lost
|
||||
// from the transcript — it just won't auto-run).
|
||||
const maxQueuedPerSession = 20
|
||||
|
||||
// messageQueue holds operator messages that arrived while a turn was already
|
||||
// running for a session. Plan 2026-08-03 (F2): instead of rejecting the
|
||||
// operator's message with "Nomos is still finishing a previous step… send it
|
||||
// again", the message is queued and auto-run when the in-flight turn releases
|
||||
// the session's turn-gate permit.
|
||||
//
|
||||
// The queue only schedules WHEN a turn runs, not WHETHER the message is stored
|
||||
// — handleChat persists the user message before acquiring the gate, so a queued
|
||||
// message is already in the transcript; this just makes sure a turn eventually
|
||||
// acts on it.
|
||||
//
|
||||
// Draining is strictly one-at-a-time under the turn gate (see drainQueued in
|
||||
// main.go), so this cannot stack concurrent turns — the exact hazard the gate
|
||||
// itself exists to prevent. Background resumeSession callers never touch this
|
||||
// queue; they keep their non-blocking skip.
|
||||
type messageQueue struct {
|
||||
mu sync.Mutex
|
||||
queue map[string][]string
|
||||
}
|
||||
|
||||
func newMessageQueue() *messageQueue {
|
||||
return &messageQueue{queue: map[string][]string{}}
|
||||
}
|
||||
|
||||
// enqueue appends a message to the back of the session's FIFO. Returns false
|
||||
// (and logs) if the session is already at maxQueuedPerSession — the caller's
|
||||
// message is already persisted in the DB, so this only skips auto-running it.
|
||||
func (q *messageQueue) enqueue(sessionID, msg string) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if len(q.queue[sessionID]) >= maxQueuedPerSession {
|
||||
slog.Warn("nomos: message queue full; dropping auto-run for operator message", "session", sessionID, "cap", maxQueuedPerSession)
|
||||
return false
|
||||
}
|
||||
q.queue[sessionID] = append(q.queue[sessionID], msg)
|
||||
return true
|
||||
}
|
||||
|
||||
// dequeue pops the next message from the front of the session's FIFO. Returns
|
||||
// ok=false when empty.
|
||||
func (q *messageQueue) dequeue(sessionID string) (string, bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
xs := q.queue[sessionID]
|
||||
if len(xs) == 0 {
|
||||
return "", false
|
||||
}
|
||||
m := xs[0]
|
||||
q.queue[sessionID] = xs[1:]
|
||||
return m, true
|
||||
}
|
||||
|
||||
// requeueFront pushes a message back to the front — used when a drainer popped
|
||||
// a message but lost the race for the gate to a live turn; that turn's own
|
||||
// release will drain it again.
|
||||
func (q *messageQueue) requeueFront(sessionID, msg string) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
q.queue[sessionID] = append([]string{msg}, q.queue[sessionID]...)
|
||||
}
|
||||
|
||||
// peek reports the queued depth for a session (test/diagnostic helper).
|
||||
func (q *messageQueue) peek(sessionID string) int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return len(q.queue[sessionID])
|
||||
}
|
||||
142
cmd/nomos/messagequeue_test.go
Normal file
142
cmd/nomos/messagequeue_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMessageQueue_FIFO(t *testing.T) {
|
||||
q := newMessageQueue()
|
||||
q.enqueue("s", "first")
|
||||
q.enqueue("s", "second")
|
||||
q.enqueue("s", "third")
|
||||
|
||||
want := []string{"first", "second", "third"}
|
||||
for _, w := range want {
|
||||
got, ok := q.dequeue("s")
|
||||
if !ok || got != w {
|
||||
t.Fatalf("dequeue = %q,%v want %q,true", got, ok, w)
|
||||
}
|
||||
}
|
||||
if _, ok := q.dequeue("s"); ok {
|
||||
t.Fatal("dequeue on drained queue should return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageQueue_RequeueFront(t *testing.T) {
|
||||
q := newMessageQueue()
|
||||
q.enqueue("s", "a")
|
||||
q.enqueue("s", "b")
|
||||
// Pop "a", then push it back to the front; "a" must come out before "b".
|
||||
a, _ := q.dequeue("s")
|
||||
q.requeueFront("s", a)
|
||||
got, _ := q.dequeue("s")
|
||||
if got != "a" {
|
||||
t.Fatalf("after requeueFront, dequeue = %q want %q", got, "a")
|
||||
}
|
||||
got2, _ := q.dequeue("s")
|
||||
if got2 != "b" {
|
||||
t.Fatalf("next dequeue = %q want %q", got2, "b")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageQueue_IsolatedPerSession(t *testing.T) {
|
||||
q := newMessageQueue()
|
||||
q.enqueue("s1", "one")
|
||||
q.enqueue("s2", "two")
|
||||
if got, _ := q.dequeue("s1"); got != "one" {
|
||||
t.Fatalf("s1 = %q want one", got)
|
||||
}
|
||||
if got, _ := q.dequeue("s2"); got != "two" {
|
||||
t.Fatalf("s2 = %q want two", got)
|
||||
}
|
||||
if q.peek("s1") != 0 || q.peek("s2") != 0 {
|
||||
t.Fatal("both sessions should be drained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageQueue_Concurrent(t *testing.T) {
|
||||
q := newMessageQueue()
|
||||
const n = maxQueuedPerSession // stay under the cap so every enqueue lands
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
q.enqueue("s", "m")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if q.peek("s") != n {
|
||||
t.Fatalf("peek = %d want %d (all enqueues must be counted)", q.peek("s"), n)
|
||||
}
|
||||
seen := 0
|
||||
for {
|
||||
if _, ok := q.dequeue("s"); !ok {
|
||||
break
|
||||
}
|
||||
seen++
|
||||
}
|
||||
if seen != n {
|
||||
t.Fatalf("drained %d want %d", seen, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageQueue_CapsOverflow(t *testing.T) {
|
||||
q := newMessageQueue()
|
||||
for i := 0; i < maxQueuedPerSession; i++ {
|
||||
if !q.enqueue("s", "m") {
|
||||
t.Fatalf("enqueue #%d within cap should succeed", i)
|
||||
}
|
||||
}
|
||||
if q.enqueue("s", "overflow") {
|
||||
t.Fatal("enqueue past the cap should return false (dropped)")
|
||||
}
|
||||
if got := q.peek("s"); got != maxQueuedPerSession {
|
||||
t.Fatalf("peek = %d want %d (overflow must not append)", got, maxQueuedPerSession)
|
||||
}
|
||||
}
|
||||
|
||||
// drainQueued on an empty queue must be a no-op: it returns immediately and
|
||||
// never touches the gate (so the session stays free for the next turn).
|
||||
func TestDrainQueued_NoOpOnEmpty(t *testing.T) {
|
||||
a := &agent{gate: newTurnGate(), queue: newMessageQueue()}
|
||||
a.drainQueued(context.Background(), "s")
|
||||
if !a.gate.acquire("s", 0) {
|
||||
t.Fatal("gate should be free after a no-op drain (drain must not hold it)")
|
||||
}
|
||||
a.gate.release("s")
|
||||
}
|
||||
|
||||
// With a queued message but the gate held by another turn, drainQueued must
|
||||
// re-queue the message and return WITHOUT running a turn (no store/provider → a
|
||||
// real run would panic). This is the "never stack" property: a busy gate
|
||||
// defers to the holder's own release-drain.
|
||||
func TestDrainQueued_RequeuesWhenBusy(t *testing.T) {
|
||||
prev := drainAcquireWait
|
||||
drainAcquireWait = 10 * time.Millisecond
|
||||
t.Cleanup(func() { drainAcquireWait = prev })
|
||||
|
||||
a := &agent{gate: newTurnGate(), queue: newMessageQueue()}
|
||||
if !a.gate.acquire("s", 0) {
|
||||
t.Fatal("precondition: hold the gate")
|
||||
}
|
||||
a.queue.enqueue("s", "queued-msg")
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
a.drainQueued(context.Background(), "s") // must not panic; must requeue
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("drainQueued did not return promptly while the gate was busy")
|
||||
}
|
||||
if got := a.queue.peek("s"); got != 1 {
|
||||
t.Fatalf("message should be re-queued while busy; peek = %d want 1", got)
|
||||
}
|
||||
a.gate.release("s")
|
||||
}
|
||||
@@ -807,10 +807,11 @@ func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
|
||||
// Replace any prior plan steps (done/running/pending/...) as `replaced`.
|
||||
// The rows are kept for the generation counter + audit trail; proposePlan
|
||||
// excludes `replaced` from its in-flight check, so the next propose_plan
|
||||
// takes the fresh-generation path.
|
||||
// takes the fresh-generation path. replaced_reason records the cause
|
||||
// (2026-08-04 plan-step integrity audit).
|
||||
s.pool.Exec(ctx,
|
||||
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
|
||||
sessionID)
|
||||
`UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
|
||||
sessionID, "goal superseded")
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE agent_sessions SET goal = $2, status = 'executing', title = $2, last_active_at = now() WHERE id = $1`,
|
||||
sessionID, goal); err != nil {
|
||||
@@ -850,6 +851,14 @@ func (s *store) reopenSession(ctx context.Context, sessionID string) bool {
|
||||
s.pool.Exec(ctx,
|
||||
`UPDATE agent_sessions SET status = 'executing', outcome = NULL, summary = NULL, last_active_at = now() WHERE id = $1`,
|
||||
sessionID)
|
||||
// Mark the prior plan's steps as replaced so the P1 plan-first gate in
|
||||
// classifyAndGate forces a fresh propose_plan before any run. Without
|
||||
// this, the agent could resume a session and call run against the old
|
||||
// (completed) plan — exactly what caused the ZimaOS continuation to
|
||||
// have 81 ad-hoc tool calls with zero plan structure (2026-08-04).
|
||||
s.pool.Exec(ctx,
|
||||
`UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
|
||||
sessionID, "session reopened — awaiting new plan")
|
||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.reopened", s.taskEntityPtr(ctx, sessionID),
|
||||
"info", "nomos", sessionID, map[string]any{"prior_status": currentStatus})
|
||||
return true
|
||||
@@ -908,9 +917,11 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
||||
// The rows are kept for the generation counter (MAX(generation)+1 below)
|
||||
// and the plan_generations eval assertion. `replaced` steps are excluded
|
||||
// from the anyStarted check above, so they don't block this proposal.
|
||||
// replaced_reason records the cause — required by the plan-step integrity
|
||||
// gate (2026-08-04 session audit).
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status = 'pending'`,
|
||||
sessionID); err != nil {
|
||||
`UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status = 'pending'`,
|
||||
sessionID, "superseded by new plan generation"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -972,7 +983,7 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
||||
// be marked complete while an earlier step is still pending, preventing the
|
||||
// agent from marking step 5 done before step 4 (observed in production: the
|
||||
// agent rushed to close all steps in a final turn, in reverse order).
|
||||
func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID string) error {
|
||||
func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID, replacedReason string) error {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return nil
|
||||
}
|
||||
@@ -1025,16 +1036,30 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
|
||||
// status <> 'replaced' is defense-in-depth: MAX(generation) can't hold a
|
||||
// replaced row, but if it ever could, this refuses the write instead of
|
||||
// resurrecting it. No matching row → errPlanStepNotFound (stale/out-of-range seq).
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
UPDATE session_plan_steps
|
||||
SET status = $4, execution_id = COALESCE($5, execution_id)`+stamp+`
|
||||
WHERE session_id = $1 AND generation = $2 AND seq = $3 AND status <> 'replaced'
|
||||
RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr).Scan(&stepID, &targetSlug)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return errPlanStepNotFound
|
||||
if status == "replaced" && replacedReason != "" {
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
UPDATE session_plan_steps
|
||||
SET status = $4, execution_id = COALESCE($5, execution_id), replaced_reason = $6`+stamp+`
|
||||
WHERE session_id = $1 AND generation = $2 AND seq = $3 AND status <> 'replaced'
|
||||
RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr, replacedReason).Scan(&stepID, &targetSlug)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return errPlanStepNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
UPDATE session_plan_steps
|
||||
SET status = $4, execution_id = COALESCE($5, execution_id)`+stamp+`
|
||||
WHERE session_id = $1 AND generation = $2 AND seq = $3 AND status <> 'replaced'
|
||||
RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr).Scan(&stepID, &targetSlug)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return errPlanStepNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Anchor the event to the step's target entity when it has one, else the task.
|
||||
entPtr := s.taskEntityPtr(ctx, sessionID)
|
||||
@@ -1218,9 +1243,141 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
|
||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID,
|
||||
map[string]any{"status": status, "outcome": outcome, "summary": summary,
|
||||
"cancelled_executions": cancelledCount, "blocker": blocker})
|
||||
// Auto-persist knowledge so the graph learns from this session regardless
|
||||
// of whether the agent remembered to call upsert_knowledge (2026-08-04
|
||||
// session audit: only 2.4% of sessions called upsert_knowledge manually).
|
||||
if outcome == "success" || outcome == "partial" {
|
||||
autoUpsertKnowledge(ctx, s, sessionID, outcome, summary)
|
||||
}
|
||||
// Plan quality metric: compute step completion rate for the session's
|
||||
// current plan generation. Tracked as a task attribute so the trend
|
||||
// can be monitored over time (2026-08-04 session audit: 38% baseline).
|
||||
writePlanCompletionRate(ctx, s, sessionID)
|
||||
// Auto-feedback: create a feedback entry linking the session's outcome
|
||||
// to its last execution, feeding the pattern-extraction pipeline that
|
||||
// has been empty since launch (2026-08-04 session audit: 0 feedback rows).
|
||||
if outcome == "success" || outcome == "partial" {
|
||||
autoFeedback(ctx, s, sessionID, outcome, summary)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// autoUpsertKnowledge creates a knowledge entry for a completed session,
|
||||
// capturing what was done and linking it to the entities involved. Called
|
||||
// automatically from completeTask so every session leaves a trace, even if
|
||||
// the agent forgot to call upsert_knowledge. Only fired for success/partial
|
||||
// outcomes (failures don't have actionable discoveries).
|
||||
func autoUpsertKnowledge(ctx context.Context, s *store, sessionID, outcome, summary string) {
|
||||
var goal string
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`,
|
||||
sessionID).Scan(&goal); err != nil || goal == "" {
|
||||
return
|
||||
}
|
||||
title := "Session " + sessionID[:8] + ": " + goal
|
||||
if len(title) > 200 {
|
||||
title = title[:200]
|
||||
}
|
||||
content := "## Outcome\n" + outcome + "\n\n## Summary\n" + summary
|
||||
kind := "investigation"
|
||||
slug := "investigation:nomos/" + sessionID
|
||||
tags := []string{"nomos-session", "auto-generated"}
|
||||
|
||||
// Upsert the knowledge entity.
|
||||
docID, _ := uuid.NewV7()
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO entities (id, slug, type, name, attributes)
|
||||
VALUES ($1, $2, $3, $4, '{}')
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
|
||||
RETURNING id`, docID, slug, kind, title).Scan(&docID); err != nil {
|
||||
slog.Warn("nomos: autoUpsertKnowledge entity insert", "session", sessionID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Upsert the knowledge content.
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO knowledge_entities (entity_id, title, content, source, tags, updated_at)
|
||||
VALUES ($1, $2, $3, 'nomos-agent', $4, now())
|
||||
ON CONFLICT (entity_id) DO UPDATE
|
||||
SET title = EXCLUDED.title, content = EXCLUDED.content,
|
||||
tags = EXCLUDED.tags, updated_at = now()`,
|
||||
docID, title, content, tags); err != nil {
|
||||
slog.Warn("nomos: autoUpsertKnowledge content insert", "session", sessionID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Link to the task entity.
|
||||
var taskEntID uuid.UUID
|
||||
if s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`,
|
||||
sessionID).Scan(&taskEntID) == nil && taskEntID != uuid.Nil {
|
||||
s.pool.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, 'involves', '{"by":"nomos","auto":true}'::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = 'involves' AND valid_to IS NULL)`,
|
||||
taskEntID, docID)
|
||||
}
|
||||
|
||||
slog.Info("nomos: auto-upserted knowledge for session",
|
||||
"session", sessionID, "outcome", outcome, "slug", slug)
|
||||
}
|
||||
|
||||
// writePlanCompletionRate computes the step completion rate for the current
|
||||
// plan generation and writes it as a task entity attribute so the trend can
|
||||
// be tracked. Baseline from 2026-08-04 audit: 38% (15/39 steps reached done).
|
||||
func writePlanCompletionRate(ctx context.Context, s *store, sessionID string) {
|
||||
var total, completed int
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END), 0)
|
||||
FROM session_plan_steps
|
||||
WHERE session_id = $1
|
||||
AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1)
|
||||
AND status <> 'replaced'`, sessionID).Scan(&total, &completed)
|
||||
if total > 0 {
|
||||
rate := float64(completed) / float64(total)
|
||||
attrs, _ := json.Marshal(map[string]any{"plan_completion_rate": rate, "plan_steps_total": total, "plan_steps_completed": completed})
|
||||
s.pool.Exec(ctx, `
|
||||
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
|
||||
WHERE id = (SELECT entity_id FROM agent_sessions WHERE id = $1)`,
|
||||
sessionID, string(attrs))
|
||||
slog.Info("nomos: plan completion rate", "session", sessionID, "rate", fmt.Sprintf("%.0f%%", rate*100),
|
||||
"completed", completed, "total", total)
|
||||
}
|
||||
}
|
||||
|
||||
// autoFeedback creates a feedback entry linking the session's outcome to its
|
||||
// last execution, feeding the pattern-extraction pipeline that has been empty
|
||||
// since launch. Only created for success/partial outcomes (failures don't
|
||||
// have a specific execution to tie to).
|
||||
func autoFeedback(ctx context.Context, s *store, sessionID, outcome, summary string) {
|
||||
// Find the last execution linked to this session.
|
||||
var execID uuid.UUID
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT pe.execution_id FROM nomos_plan_executions pe
|
||||
WHERE pe.session_id = $1::uuid
|
||||
ORDER BY pe.created_at DESC LIMIT 1`, sessionID).Scan(&execID); err != nil || execID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
fbID, _ := uuid.NewV7()
|
||||
slug := "feedback:" + fbID.String()
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'feedback', $3, '{}')`,
|
||||
fbID, slug, "feedback for "+sessionID[:8]); err != nil {
|
||||
slog.Warn("nomos: autoFeedback entity insert", "session", sessionID, "error", err)
|
||||
return
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO feedback (entity_id, execution_id, outcome, observation, lesson, tags, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now())`,
|
||||
fbID, execID, outcome, summary, summary, []string{"nomos-session", "auto-generated", "session:" + sessionID[:8]})
|
||||
if err != nil {
|
||||
slog.Warn("nomos: autoFeedback insert", "session", sessionID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("nomos: auto-feedback created for session", "session", sessionID, "outcome", outcome)
|
||||
}
|
||||
|
||||
// blockerPatterns maps a substring (case-insensitive) to a structured blocker
|
||||
// reason. Order matters — earlier patterns take precedence. These are the
|
||||
// recurring failure signatures from the 2026-07-20 session audit. A
|
||||
@@ -1310,6 +1467,53 @@ func (s *store) hadDiscovery(ctx context.Context, sessionID string) bool {
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// sessionGoal returns the session's goal text, empty string if not found.
|
||||
// Used by complete_task to check whether the goal involved a reachability
|
||||
// verification before marking success.
|
||||
func (s *store) sessionGoal(ctx context.Context, sessionID string) string {
|
||||
if s == nil || sessionID == "" {
|
||||
return ""
|
||||
}
|
||||
var goal string
|
||||
s.pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`,
|
||||
sessionID).Scan(&goal)
|
||||
return goal
|
||||
}
|
||||
|
||||
// hadRecentVerification checks whether the session successfully verified
|
||||
// reachability in recent turns — ping_service, or a run with curl/wget that
|
||||
// returned successfully. Used by complete_task as a soft warning when the
|
||||
// goal involved a reachability check but no recent verification occurred.
|
||||
func (s *store) hadRecentVerification(ctx context.Context, sessionID string) bool {
|
||||
if s == nil || sessionID == "" {
|
||||
return true // fail safe: don't warn when we can't check
|
||||
}
|
||||
// Check for ping_service calls in the last 5 activity entries for this session.
|
||||
var pingCount int
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM agent_activity
|
||||
WHERE session_id = $1 AND tool_name = 'ping_service' AND success = true
|
||||
ORDER BY ts DESC LIMIT 5
|
||||
) sub`, sessionID).Scan(&pingCount)
|
||||
if pingCount > 0 {
|
||||
return true
|
||||
}
|
||||
// Check for run calls with curl/wget that returned successfully.
|
||||
var curlCount int
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM agent_activity
|
||||
WHERE session_id = $1
|
||||
AND tool_name = 'run'
|
||||
AND success = true
|
||||
AND (input_summary LIKE '%curl%' OR input_summary LIKE '%wget%')
|
||||
ORDER BY ts DESC LIMIT 10
|
||||
) sub`, sessionID).Scan(&curlCount)
|
||||
return curlCount > 0
|
||||
}
|
||||
|
||||
// staleGoalSession is a goal-bearing task that's gone idle without reaching
|
||||
// a terminal state — the idle-sweep worker's work list (fix 2+3 of
|
||||
// plans/2026-07-11-task-completion-safety-net.md).
|
||||
@@ -1913,7 +2117,7 @@ func (s *store) resolveArgEntityID(ctx context.Context, args map[string]any) uui
|
||||
// The (nullable) session_id column carries the conversation id. args is the
|
||||
// tool call's own arguments, used to best-effort tag the row with the
|
||||
// entity it acted on (see resolveArgEntityID).
|
||||
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) {
|
||||
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string, tokenCount int) {
|
||||
if s == nil || agentID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
@@ -1925,8 +2129,8 @@ func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, t
|
||||
s.pool.Exec(ctx, `
|
||||
INSERT INTO agent_activity
|
||||
(agent_id, session_id, activity_type, tool_name, entity_id, input_summary, output_summary,
|
||||
duration_ms, success, correlation_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
duration_ms, success, correlation_id, token_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
agentID, sessionID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
|
||||
durationMs, success, correlationID)
|
||||
durationMs, success, correlationID, tokenCount)
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
|
||||
}
|
||||
|
||||
// Mark step 1 as started.
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", ""); err != nil {
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
|
||||
t.Fatalf("updatePlanStep: %v", err)
|
||||
}
|
||||
|
||||
@@ -288,10 +288,10 @@ func TestUpdatePlanStep_GenerationRelative(t *testing.T) {
|
||||
|
||||
// The model addresses the new plan with 1-based seq. seq=1 must hit
|
||||
// gen-2 "C", leaving gen-1 "A" (replaced) untouched.
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", ""); err != nil {
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
|
||||
t.Fatalf("updatePlanStep(seq=1, running): %v", err)
|
||||
}
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "done", ""); err != nil {
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "done", "", ""); err != nil {
|
||||
t.Fatalf("updatePlanStep(seq=1, done): %v", err)
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ func TestUpdatePlanStep_GenerationRelative(t *testing.T) {
|
||||
}
|
||||
|
||||
// Out-of-range seq must be refused (no current-gen step there).
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 99, "running", ""); !errors.Is(err, errPlanStepNotFound) {
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 99, "running", "", ""); !errors.Is(err, errPlanStepNotFound) {
|
||||
t.Fatalf("updatePlanStep(seq=99) err = %v, want errPlanStepNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,7 @@ func TestCompleteTask_AutoCloseEmitsEvents(t *testing.T) {
|
||||
t.Fatalf("proposePlan: %v", err)
|
||||
}
|
||||
// A is running, B still pending at completion time.
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", ""); err != nil {
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
|
||||
t.Fatalf("updatePlanStep(1, running): %v", err)
|
||||
}
|
||||
if err := s.completeTask(ctx, sess.ID, "success", "done"); err != nil {
|
||||
@@ -397,7 +397,7 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
|
||||
|
||||
// A `run` call (discovery) — should set hadDiscovery, not hadEntityWriteback.
|
||||
agentID := uuid.New()
|
||||
s.logActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1")
|
||||
s.logActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1", 0)
|
||||
if !s.hadDiscovery(ctx, sess.ID) {
|
||||
t.Fatal("hadDiscovery = false after a successful run call, want true")
|
||||
}
|
||||
@@ -410,7 +410,7 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
s.logActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2")
|
||||
s.logActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2", 0)
|
||||
if s.hadDiscovery(ctx, sess2.ID) {
|
||||
t.Fatal("hadDiscovery = true after a failed run call, want false (no facts learned)")
|
||||
}
|
||||
@@ -420,7 +420,7 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
s.logActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3")
|
||||
s.logActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3", 0)
|
||||
if s.hadDiscovery(ctx, sess3.ID) {
|
||||
t.Fatal("hadDiscovery = true after get_entity, want false (DB lookups are not discovery)")
|
||||
}
|
||||
@@ -430,12 +430,12 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
s.logActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4")
|
||||
s.logActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4", 0)
|
||||
if !s.hadEntityWriteback(ctx, sess4.ID) {
|
||||
t.Fatal("hadEntityWriteback = false after update_entity_attributes, want true")
|
||||
}
|
||||
// And the discovery+writeback combination (the conv3 scenario).
|
||||
s.logActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5")
|
||||
s.logActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5", 0)
|
||||
if !s.hadDiscovery(ctx, sess4.ID) {
|
||||
t.Fatal("hadDiscovery = false after run+writeback, want true")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -306,7 +307,8 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
if seq <= 0 || status == "" {
|
||||
return "error: update_plan_step needs seq (>=1) and status", true
|
||||
}
|
||||
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID); err != nil {
|
||||
reason, _ := args["replaced_reason"].(string)
|
||||
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID, reason); err != nil {
|
||||
if errors.Is(err, errPlanStepNotFound) {
|
||||
// The seq doesn't address a step in the CURRENT plan — most
|
||||
// often a stale 1-based number the model carried across a
|
||||
@@ -373,6 +375,18 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) && !a.store.hadEntityWriteback(ctx, sessionID) {
|
||||
return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true
|
||||
}
|
||||
// D.2: refuse success when the goal mentions a reachability/uptime
|
||||
// check but no verification was done. The agent can't claim "X is
|
||||
// reachable" based on a shell command alone — the proxy (Caddy) can
|
||||
// return 200 for a terminal page (ttyd) or fallback while the actual
|
||||
// dashboard is still down. Must call ping_service or run a successful
|
||||
// curl before claiming success.
|
||||
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) {
|
||||
goal := a.store.sessionGoal(ctx, sessionID)
|
||||
if mentionsReachability(goal) && !a.store.hadRecentVerification(ctx, sessionID) {
|
||||
return "Refused: the goal involves a reachability or uptime check (\"make X reachable\", \"get X up\", etc.), but no ping_service call or successful curl/HTTP request against the target was detected. Caddy can return 200 for a terminal or fallback page while the actual service is still down — you must verify the service itself, not just the proxy. Call ping_service(target) or run a curl against the actual service URL, then call complete_task again. Outcome held until verified.", true
|
||||
}
|
||||
}
|
||||
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
|
||||
if errors.Is(err, errTaskAlreadyComplete) {
|
||||
return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true
|
||||
@@ -389,6 +403,28 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
}
|
||||
}
|
||||
|
||||
// reachabilityPatterns matches goal text that involves making something
|
||||
// reachable/accessible/working. Used by complete_task to surface a soft
|
||||
// warning when the session goal was about reachability but no verification
|
||||
// occurred before marking success.
|
||||
var reachabilityPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)https?://[^\s]+`),
|
||||
regexp.MustCompile(`(?i)\.hubris\.net\w+`),
|
||||
regexp.MustCompile(`(?i)(un)?reachable`),
|
||||
regexp.MustCompile(`(?i)(not?\s+)?(accessible|reachable|responding|resolving)`),
|
||||
regexp.MustCompile(`(?i)diagnose\s+why`),
|
||||
regexp.MustCompile(`(?i)(fix|restore|bring\s+back).*(accessible|reachable|online)`),
|
||||
}
|
||||
|
||||
func mentionsReachability(goal string) bool {
|
||||
for _, p := range reachabilityPatterns {
|
||||
if p.MatchString(goal) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// autoCompleteTrivialTask is the case-1 fix from
|
||||
// plans/2026-07-11-task-completion-safety-net.md: a session that never
|
||||
// called set_goal never framed itself as a structured task, so a turn that
|
||||
|
||||
@@ -8,7 +8,8 @@ FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/vendor /build/vendor
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY VERSION ./
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
@@ -234,9 +234,33 @@ sequenceDiagram
|
||||
|
||||
---
|
||||
|
||||
**2026-07-08 — renamed to Nomos.** The Hermes agent gateway was renamed to
|
||||
**2026-07-08 — renamed to Nomos.**
|
||||
Nomos (from *oikonomos*, the steward of the oikos) under the
|
||||
[Nomos resident agent plan](../../plans/2026-07-08-nomos-resident-agent.md),
|
||||
|
||||
### Hermes MCP client setup
|
||||
|
||||
To connect a Hermes Agent instance to oikos as a native MCP client, add to
|
||||
`~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
mcp_servers:
|
||||
oikos:
|
||||
url: "https://mcp.hubris.network/mcp"
|
||||
headers:
|
||||
Authorization: "Bearer <OIKOS_MCP_BEARER_TOKEN>"
|
||||
timeout: 180
|
||||
```
|
||||
|
||||
Run `/reload-mcp` in-session or restart Hermes. Tools appear as
|
||||
`mcp__oikos__*`.
|
||||
|
||||
**Caveat:** Hermes stores the bearer token in plaintext in `config.yaml` —
|
||||
it does not support `${VAR}` interpolation in MCP server headers. Ensure
|
||||
`security.redact_secrets: true` (default) so the token value is stripped
|
||||
from tool output and logs. File an upstream feature request at
|
||||
https://github.com/NousResearch/hermes-agent/issues for env-var
|
||||
interpolation support.
|
||||
N0 milestone. The gateway binary (`cmd/nomos`), Docker service, DB slug
|
||||
(`agent:nomos`), and all referencing docs were updated. All architectural
|
||||
principles in this ADR remain unchanged.
|
||||
@@ -33,6 +33,7 @@ const (
|
||||
KindBackup = "backup-freshness"
|
||||
KindCertExpiry = "cert-expiry"
|
||||
KindVMStatus = "vm-status"
|
||||
KindDNS = "dns"
|
||||
)
|
||||
|
||||
// defaultBackupMaxAge is how long a backup target may go without a new
|
||||
@@ -306,6 +307,23 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
|
||||
interval: 60,
|
||||
}}, ""
|
||||
|
||||
case KindDNS:
|
||||
// Resolve the entity's name via DNS to verify the zone is reachable.
|
||||
// Uses the entity name (zone apex) or falls back to the slug.
|
||||
name := t.Name
|
||||
if name == "" {
|
||||
name = strings.TrimPrefix(t.Slug, "zone:")
|
||||
}
|
||||
if name == "" {
|
||||
return nil, "no name to resolve"
|
||||
}
|
||||
return []checkDef{{
|
||||
kind: "dns",
|
||||
config: map[string]any{"name": name},
|
||||
interval: 300, // 5 min — DNS changes are rare; the cost of a miss
|
||||
// is a stale IP, not a service outage.
|
||||
}}, ""
|
||||
|
||||
case KindCertExpiry:
|
||||
// The host whose cert to read (SNI / cert CN). Prefer an explicit
|
||||
// `hostname` attribute, then `cn`, then a dotted name. Hourly: expiry
|
||||
|
||||
34
internal/db/checks.go
Normal file
34
internal/db/checks.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// EnsureEntityChecks derives an entity's default check_defs from the
|
||||
// monitoring spec of its type (resolving per-entity `monitoring` overrides).
|
||||
//
|
||||
// This is the single shared hook that keeps the check graph in sync with
|
||||
// entity mutations. Both the HTTP create/patch handlers and the MCP
|
||||
// entity-mutation tools (create_entity, update_entity_attributes) call it so
|
||||
// that flipping an entity's `monitoring` attribute regenerates checks
|
||||
// regardless of which surface made the change — previously only the HTTP
|
||||
// path ran check derivation, so entities mutated via MCP silently produced no
|
||||
// checks (see plans/2026-08-03-session-review-haos-monitoring-capability-gaps.md, A2).
|
||||
func EnsureEntityChecks(ctx context.Context, tx pgx.Tx, id uuid.UUID, slug, entityType, name string, attrs []byte) (checkdefaults.Result, error) {
|
||||
tree, err := LoadTypeTree(ctx, tx)
|
||||
if err != nil {
|
||||
return checkdefaults.Result{}, err
|
||||
}
|
||||
res, err := checkdefaults.Ensure(ctx, tx, tree, checkdefaults.Target{
|
||||
ID: id, Slug: slug, Type: entityType, Name: name, Attrs: attrs,
|
||||
})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
checkdefaults.LogResult(slug, entityType, res)
|
||||
return res, nil
|
||||
}
|
||||
146
internal/db/lifecycle.go
Normal file
146
internal/db/lifecycle.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrTransitionInvalid is a sentinel returned by ValidateTransition when the
|
||||
// from→to pair is not a declared lifecycle transition or a precondition fails.
|
||||
// Callers test with errors.Is to distinguish semantic validation failures
|
||||
// (→ HTTP 409) from infrastructure errors (→ HTTP 500).
|
||||
var ErrTransitionInvalid = errors.New("invalid lifecycle transition")
|
||||
|
||||
// ValidateTransition enforces an entity type's lifecycle: fromState → toState
|
||||
// must be a declared transition, and every precondition it lists must hold. A
|
||||
// type with no lifecycle defined allows any state. A no-op (fromState ==
|
||||
// toState) passes immediately.
|
||||
//
|
||||
// Shared by the HTTP PATCH path and the MCP set_entity_state tool so both
|
||||
// surfaces apply identical lifecycle rules — previously only the HTTP path
|
||||
// validated transitions, so an agent changing state via MCP could skip the
|
||||
// graph's retire/deprecate guardrails entirely.
|
||||
func ValidateTransition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, fromState, toState string) error {
|
||||
if toState == fromState {
|
||||
return nil
|
||||
}
|
||||
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, entityType)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil // no lifecycle defined → any state allowed
|
||||
}
|
||||
return err
|
||||
}
|
||||
var transitions map[string]map[string]json.RawMessage
|
||||
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
|
||||
return fmt.Errorf("parse lifecycle transitions: %w", err)
|
||||
}
|
||||
tos, ok := transitions[fromState]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: no transitions defined from %q", ErrTransitionInvalid, fromState)
|
||||
}
|
||||
trans, ok := tos[toState]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s → %s is not a declared lifecycle transition", ErrTransitionInvalid, fromState, toState)
|
||||
}
|
||||
var gate struct {
|
||||
Requires []string `json:"requires"`
|
||||
}
|
||||
if err := json.Unmarshal(trans, &gate); err == nil {
|
||||
for _, check := range gate.Requires {
|
||||
if err := checkPrecondition(ctx, tx, entityID, entityType, check); err != nil {
|
||||
return fmt.Errorf("%w: precondition %q not met: %w", ErrTransitionInvalid, check, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPrecondition evaluates one mechanical precondition named by a lifecycle
|
||||
// transition's `requires` list. Soft/operator-confirmed checks pass; unknown
|
||||
// checks are skipped (operator intent overrides). Moved here from httpapi so
|
||||
// both surfaces share one implementation.
|
||||
func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error {
|
||||
switch check {
|
||||
case "no-inbound-edges":
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx,
|
||||
"SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("%d inbound relationship edges remaining", count)
|
||||
}
|
||||
case "backups-verified", "secrets-revoked", "ingress-dns-removed":
|
||||
var attrs string
|
||||
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
|
||||
return err
|
||||
}
|
||||
want := map[string]string{
|
||||
"backups-verified": "backups_verified",
|
||||
"secrets-revoked": "secrets_revoked",
|
||||
"ingress-dns-removed": "ingress_dns_removed",
|
||||
}[check]
|
||||
if !strings.Contains(attrs, want) {
|
||||
return fmt.Errorf("%s not recorded in entity attributes", want)
|
||||
}
|
||||
case "age-key-enrolled-if-needed":
|
||||
if entityType == "workstation" {
|
||||
var attrs string
|
||||
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(attrs, "age_pubkey") {
|
||||
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
|
||||
}
|
||||
}
|
||||
case "mesh-joined-if-needed":
|
||||
if entityType == "workstation" {
|
||||
var attrs string
|
||||
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(attrs, "mesh_ip") {
|
||||
return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
|
||||
}
|
||||
}
|
||||
case "health-check-answering":
|
||||
st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
|
||||
if err != nil || st.Health == "unknown" || st.Health == "down" {
|
||||
h := "unknown"
|
||||
if err == nil {
|
||||
h = st.Health
|
||||
}
|
||||
return fmt.Errorf("health check not answering (status: %s)", h)
|
||||
}
|
||||
case "doc-page-complete":
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT count(*) FROM relationships r
|
||||
JOIN entities ke ON ke.id = r.source_id
|
||||
WHERE r.target_id = $1 AND r.valid_to IS NULL
|
||||
AND r.type = 'documents' AND ke.type IN ('document','runbook','investigation')`,
|
||||
entityID).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fmt.Errorf("no documentation linked to entity")
|
||||
}
|
||||
case "inventory-entry", "ip-reserved", "storage-pool-chosen", "cancelled-note",
|
||||
"preflight-passed", "error-summary", "replacement-live-or-role-retired",
|
||||
"replacement-failed", "post-verify-passed", "recovery-verified", "written-off",
|
||||
"ingress-live-if-public", "doc-page-stub", "un-deprecate-note", "write-off-note":
|
||||
// Soft checks — always pass. Operator-confirmed via the transition
|
||||
// request itself, or not mechanically enforceable.
|
||||
default:
|
||||
// Unknown preconditions are skipped (operator intent overrides).
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -25,8 +25,8 @@ ON CONFLICT (actor, key) DO NOTHING;
|
||||
|
||||
-- name: InsertAuditEntry :exec
|
||||
INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path,
|
||||
status_code, detail, source_ip, correlation_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
|
||||
status_code, detail, source_ip, correlation_id, session_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
|
||||
|
||||
-- name: InsertEvent :one
|
||||
INSERT INTO events (type, entity_id, severity, source, data, correlation_id)
|
||||
|
||||
@@ -380,6 +380,8 @@ type RelationshipType struct {
|
||||
Cardinality string
|
||||
Description *string
|
||||
CreatedAt time.Time
|
||||
// Which end of this edge depends on the other. forward = target depends on source. backward = source depends on target. none = no runtime dependency. Drives blast_radius().
|
||||
BlastDirection string
|
||||
}
|
||||
|
||||
type RiskClass struct {
|
||||
@@ -396,18 +398,19 @@ type SeedVersion struct {
|
||||
}
|
||||
|
||||
type SessionPlanStep struct {
|
||||
ID uuid.UUID
|
||||
SessionID uuid.UUID
|
||||
Seq int32
|
||||
Title string
|
||||
Detail string
|
||||
Status string
|
||||
ExecutionID *uuid.UUID
|
||||
TargetSlug *string
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
Generation int32
|
||||
ID uuid.UUID
|
||||
SessionID uuid.UUID
|
||||
Seq int32
|
||||
Title string
|
||||
Detail string
|
||||
Status string
|
||||
ExecutionID *uuid.UUID
|
||||
TargetSlug *string
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
Generation int32
|
||||
ReplacedReason *string
|
||||
}
|
||||
|
||||
type SessionQuestion struct {
|
||||
|
||||
@@ -99,7 +99,7 @@ func (q *Queries) ListLifecycleDefs(ctx context.Context) ([]LifecycleDef, error)
|
||||
}
|
||||
|
||||
const listRelationshipTypes = `-- name: ListRelationshipTypes :many
|
||||
SELECT name, inverse, source_type, target_type, cardinality, description, created_at FROM relationship_types ORDER BY name
|
||||
SELECT name, inverse, source_type, target_type, cardinality, description, created_at, blast_direction FROM relationship_types ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType, error) {
|
||||
@@ -119,6 +119,7 @@ func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType
|
||||
&i.Cardinality,
|
||||
&i.Description,
|
||||
&i.CreatedAt,
|
||||
&i.BlastDirection,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -353,8 +353,8 @@ func (q *Queries) InsertApproval(ctx context.Context, arg InsertApprovalParams)
|
||||
|
||||
const insertAuditEntry = `-- name: InsertAuditEntry :exec
|
||||
INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path,
|
||||
status_code, detail, source_ip, correlation_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
status_code, detail, source_ip, correlation_id, session_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`
|
||||
|
||||
type InsertAuditEntryParams struct {
|
||||
@@ -368,6 +368,7 @@ type InsertAuditEntryParams struct {
|
||||
Detail []byte
|
||||
SourceIp *string
|
||||
CorrelationID *string
|
||||
SessionID *uuid.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) InsertAuditEntry(ctx context.Context, arg InsertAuditEntryParams) error {
|
||||
@@ -382,6 +383,7 @@ func (q *Queries) InsertAuditEntry(ctx context.Context, arg InsertAuditEntryPara
|
||||
arg.Detail,
|
||||
arg.SourceIp,
|
||||
arg.CorrelationID,
|
||||
arg.SessionID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ func (s *Server) CreateApprovalRule(ctx context.Context, req gen.CreateApprovalR
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
|
||||
&id, "POST", "/api/v1/policy/approval-rules", "",
|
||||
nil,
|
||||
map[string]any{"action": req.Body.Action, "risk_class": req.Body.RiskClass}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
@@ -148,6 +149,7 @@ func (s *Server) PatchApprovalRule(ctx context.Context, req gen.PatchApprovalRul
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
|
||||
&id, "PATCH", "/api/v1/policy/approval-rules/"+req.Id, "",
|
||||
nil,
|
||||
map[string]any{"action": req.Body.Action}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
|
||||
|
||||
if auditErr := observability.Audit(ctx, q, actorType, actor, "decide",
|
||||
&id, "POST", "/api/v1/approvals/"+req.Id+"/decision", "",
|
||||
nil,
|
||||
map[string]any{"decision": status}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ func (s *Server) PatchAutonomySettings(ctx context.Context, req gen.PatchAutonom
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
|
||||
nil, "PATCH", "/api/v1/policy/autonomy", "",
|
||||
nil,
|
||||
map[string]any{"keys": keysOfMap(*req.Body)}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ func (s *Server) CreateCheck(ctx context.Context, req gen.CreateCheckRequestObje
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
|
||||
&id, "POST", "/api/v1/checks", "",
|
||||
nil,
|
||||
map[string]any{"kind": req.Body.Kind, "slug": slug}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
@@ -269,6 +270,7 @@ func (s *Server) PatchCheck(ctx context.Context, req gen.PatchCheckRequestObject
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
|
||||
&id, "PATCH", "/api/v1/checks/"+req.Id, "",
|
||||
nil,
|
||||
map[string]any{"enabled": updated.Enabled}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
@@ -3,31 +3,22 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ensureDefaultChecks derives an entity's default checks from the monitoring
|
||||
// kinds its type declares.
|
||||
// kinds its type declares. Thin wrapper over the shared db.EnsureEntityChecks
|
||||
// hook so the HTTP create/patch paths and the MCP entity-mutation tools stay
|
||||
// in lockstep.
|
||||
//
|
||||
// Note the ordering caveat: an entity created through the API usually has no
|
||||
// edges yet, so a type whose address comes from its host (a service) will
|
||||
// produce no checks on this pass. That gap is real and deliberately visible —
|
||||
// coverageSweep reports it, and the next inventory ingest fills it in once
|
||||
// the hosting edge exists.
|
||||
// Note the ordering caveat (carried from db.LoadTypeTree / checkdefaults.Ensure):
|
||||
// an entity created through the API usually has no edges yet, so a type whose
|
||||
// address comes from its host (a service) will produce no checks on this pass.
|
||||
// That gap is real and deliberately visible — coverageSweep reports it, and
|
||||
// the next inventory ingest fills it in once the hosting edge exists.
|
||||
func ensureDefaultChecks(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType, name string, attrsJSON []byte) error {
|
||||
tree, err := db.LoadTypeTree(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := checkdefaults.Ensure(ctx, tx, tree, checkdefaults.Target{
|
||||
ID: entityID, Slug: slug, Type: entityType, Name: name, Attrs: attrsJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
checkdefaults.LogResult(slug, entityType, res)
|
||||
return nil
|
||||
_, err := db.EnsureEntityChecks(ctx, tx, entityID, slug, entityType, name, attrsJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ func (s *Server) CreateEntityType(ctx context.Context, req gen.CreateEntityTypeR
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
|
||||
nil, "POST", "/api/v1/ontology/entity-types", "",
|
||||
nil,
|
||||
map[string]any{"name": req.Body.Name, "domain": req.Body.Domain}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
@@ -148,6 +149,7 @@ func (s *Server) PatchEntityType(ctx context.Context, req gen.PatchEntityTypeReq
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
|
||||
nil, "PATCH", "/api/v1/ontology/entity-types/"+req.Name, "",
|
||||
nil,
|
||||
map[string]any{"status": req.Body.Status}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
@@ -240,6 +240,7 @@ func (s *Server) RequestExecution(ctx context.Context, req gen.RequestExecutionR
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
|
||||
&id, "POST", "/api/v1/executions", "",
|
||||
nil,
|
||||
map[string]any{"action": req.Body.Action, "target": req.Body.Target}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
@@ -307,6 +308,7 @@ func (s *Server) CancelExecution(ctx context.Context, req gen.CancelExecutionReq
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, q, actorType, actor, "cancel",
|
||||
&id, "POST", "/api/v1/executions/"+req.Id+"/cancel", "",
|
||||
nil,
|
||||
map[string]any{"status": "cancelled"}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
@@ -817,7 +818,7 @@ func (s *Server) QueryAudit(ctx context.Context, req gen.QueryAuditRequestObject
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, ts, actor_type, actor_id::text, action, entity_id::text,
|
||||
method, path, status_code, detail, source_ip, correlation_id
|
||||
method, path, status_code, detail, source_ip, correlation_id, session_id::text
|
||||
FROM audit_log
|
||||
WHERE ($1::text IS NULL OR actor_type = $1)
|
||||
AND ($2::text IS NULL OR actor_id::text = $2)
|
||||
@@ -838,10 +839,10 @@ func (s *Server) QueryAudit(ctx context.Context, req gen.QueryAuditRequestObject
|
||||
for rows.Next() {
|
||||
var a gen.AuditEntry
|
||||
var detailBytes []byte
|
||||
var actID, entID, method, path, sourceIP, corrID *string
|
||||
var actID, entID, method, path, sourceIP, corrID, sessionID *string
|
||||
var statusCode *int
|
||||
if err := rows.Scan(&a.Id, &a.Ts, &a.ActorType, &actID, &a.Action, &entID,
|
||||
&method, &path, &statusCode, &detailBytes, &sourceIP, &corrID); err != nil {
|
||||
&method, &path, &statusCode, &detailBytes, &sourceIP, &corrID, &sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ActorId = actID
|
||||
@@ -999,6 +1000,7 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
|
||||
entityID := inserted.ID
|
||||
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
|
||||
&entityID, "POST", "/api/v1/entities", "",
|
||||
nil,
|
||||
map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
@@ -1062,49 +1064,15 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
|
||||
|
||||
// Validate lifecycle transition if state is being changed.
|
||||
if req.Body.State != nil && *req.Body.State != "" {
|
||||
// Get lifecycle def for the entity's type.
|
||||
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, current.Type)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
// No lifecycle defined — any state is allowed.
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
var transitions map[string]map[string]json.RawMessage
|
||||
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
|
||||
return nil, fmt.Errorf("parse lifecycle transitions: %w", err)
|
||||
}
|
||||
|
||||
fromState := ""
|
||||
if current.State != nil {
|
||||
fromState = *current.State
|
||||
}
|
||||
toState := *req.Body.State
|
||||
|
||||
if toState != fromState {
|
||||
tos, ok := transitions[fromState]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: no transitions from %q", domain.ErrInvalidTransition, fromState)
|
||||
}
|
||||
trans, ok := tos[toState]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
|
||||
}
|
||||
|
||||
// Parse preconditions: {"requires": ["check-name", ...]}
|
||||
var gate struct {
|
||||
Requires []string `json:"requires"`
|
||||
}
|
||||
if err := json.Unmarshal(trans, &gate); err == nil && len(gate.Requires) > 0 {
|
||||
for _, check := range gate.Requires {
|
||||
if err := checkPrecondition(ctx, tx, id, current.Type, check); err != nil {
|
||||
return nil, fmt.Errorf("%w: precondition %q not met: %v",
|
||||
domain.ErrInvalidTransition, check, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
fromState := ""
|
||||
if current.State != nil {
|
||||
fromState = *current.State
|
||||
}
|
||||
if err := db.ValidateTransition(ctx, tx, id, current.Type, fromState, *req.Body.State); err != nil {
|
||||
if errors.Is(err, db.ErrTransitionInvalid) {
|
||||
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidTransition, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1143,6 +1111,7 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
|
||||
patchActorType, patchActor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, q, patchActorType, patchActor, "patch",
|
||||
&id, "PATCH", "/api/v1/entities/"+req.Id, "",
|
||||
nil,
|
||||
map[string]any{"version": expectedVersion}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
@@ -1272,6 +1241,7 @@ func (s *Server) EnrollClient(ctx context.Context, req gen.EnrollClientRequestOb
|
||||
entityID := id
|
||||
_ = observability.Audit(ctx, q, "operator", actor, "enroll",
|
||||
&entityID, "POST", "/api/v1/clients/enroll", "",
|
||||
nil,
|
||||
map[string]any{"slug": req.Body.Slug, "mesh_ip": meshIP})
|
||||
_ = observability.Event(ctx, q, "client.enrolled", &entityID,
|
||||
"info", "oikos-api", "",
|
||||
@@ -1466,6 +1436,7 @@ func (s *Server) ProvisionEntity(ctx context.Context, req gen.ProvisionEntityReq
|
||||
_, actor := actorInfo(ctx)
|
||||
_ = observability.Audit(ctx, q, "operator", actor, "provision",
|
||||
&entityID, "POST", "/api/v1/entities/provision", "",
|
||||
nil,
|
||||
map[string]any{"slug": req.Body.Slug, "host": hostSlug})
|
||||
_ = observability.Event(ctx, q, "entity.provisioned", &entityID,
|
||||
"info", "oikos-api", "",
|
||||
@@ -1556,97 +1527,5 @@ func generateAgeKeypair() (pubKey, privKey string, err error) {
|
||||
return pub, priv, nil
|
||||
}
|
||||
|
||||
// checkPrecondition validates a named lifecycle transition precondition.
|
||||
func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error {
|
||||
switch check {
|
||||
case "no-inbound-edges":
|
||||
var count int
|
||||
err := tx.QueryRow(ctx,
|
||||
"SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("%d inbound relationship edges remaining", count)
|
||||
}
|
||||
case "backups-verified":
|
||||
var attrs string
|
||||
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(attrs, "backups_verified") {
|
||||
return fmt.Errorf("backup verification not recorded in entity attributes")
|
||||
}
|
||||
case "secrets-revoked":
|
||||
var attrs string
|
||||
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(attrs, "secrets_revoked") {
|
||||
return fmt.Errorf("secret revocation not recorded in entity attributes")
|
||||
}
|
||||
case "ingress-dns-removed":
|
||||
var attrs string
|
||||
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(attrs, "ingress_dns_removed") {
|
||||
return fmt.Errorf("ingress/DNS removal not recorded in entity attributes")
|
||||
}
|
||||
case "age-key-enrolled-if-needed":
|
||||
if entityType == "workstation" {
|
||||
var attrs string
|
||||
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(attrs, "age_pubkey") {
|
||||
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
|
||||
}
|
||||
}
|
||||
case "mesh-joined-if-needed":
|
||||
if entityType == "workstation" {
|
||||
var attrs string
|
||||
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(attrs, "mesh_ip") {
|
||||
return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
|
||||
}
|
||||
}
|
||||
case "health-check-answering":
|
||||
st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
|
||||
if err != nil || st.Health == "unknown" || st.Health == "down" {
|
||||
return fmt.Errorf("health check not answering (status: %s)", st.Health)
|
||||
}
|
||||
case "doc-page-complete":
|
||||
var count int
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT count(*) FROM relationships r
|
||||
JOIN entities ke ON ke.id = r.source_id
|
||||
WHERE r.target_id = $1 AND r.valid_to IS NULL
|
||||
AND r.type = 'documents' AND ke.type IN ('document','runbook','investigation')`,
|
||||
entityID).Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fmt.Errorf("no documentation linked to entity")
|
||||
}
|
||||
case "inventory-entry", "ip-reserved", "storage-pool-chosen", "cancelled-note",
|
||||
"preflight-passed", "error-summary", "replacement-live-or-role-retired",
|
||||
"replacement-failed", "post-verify-passed", "recovery-verified", "written-off",
|
||||
"ingress-live-if-public", "doc-page-stub":
|
||||
// Soft checks — always pass. These are operator-confirmed via the
|
||||
// transition request itself, or are not mechanically enforceable.
|
||||
default:
|
||||
// Unknown preconditions are skipped (operator intent overrides).
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -111,6 +111,7 @@ func (s *Server) PatchPattern(ctx context.Context, req gen.PatchPatternRequestOb
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, q, actorType, actor, "patch",
|
||||
&id, "PATCH", "/api/v1/patterns/"+req.Id, "",
|
||||
nil,
|
||||
map[string]any{"status": req.Body.Status, "quarantined": req.Body.Quarantined}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelations
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
|
||||
nil, "POST", "/api/v1/relationships", "",
|
||||
nil,
|
||||
map[string]any{"source": req.Body.Source, "target": req.Body.Target, "type": req.Body.Type}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
@@ -108,6 +109,7 @@ func (s *Server) EndRelationship(ctx context.Context, req gen.EndRelationshipReq
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "delete",
|
||||
nil, "DELETE", "/api/v1/relationships", "",
|
||||
nil,
|
||||
map[string]any{"source": req.Params.Source, "target": req.Params.Target, "type": req.Params.RelType}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ func (s *Server) PatchSkill(ctx context.Context, req gen.PatchSkillRequestObject
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, q, actorType, actor, "patch",
|
||||
&id, "PATCH", "/api/v1/skills/"+req.Id, "",
|
||||
nil,
|
||||
map[string]any{"status": req.Body.Status, "pinned_version": req.Body.PinnedVersion}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
251
internal/mcp/create_entity_test.go
Normal file
251
internal/mcp/create_entity_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package mcp
|
||||
|
||||
// Integration tests for the entity-mutation MCP tools (create_entity,
|
||||
// update_entity_attributes), focused on the capability gap that stranded
|
||||
// session 23da10db: entities mutated via MCP must derive/regenerate checks the
|
||||
// same way the HTTP create/patch paths do. Guarded by OIKOS_TEST_DATABASE_URL
|
||||
// (see internal/db/integration_test.go); run via `make test-db`.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// newTestPool mirrors internal/httpapi/api_test.go: a throwaway database,
|
||||
// migrated and seeded with ontology/inventory/policy so create_entity's type
|
||||
// validation and checkdefaults derivation have a real type tree to work
|
||||
// against.
|
||||
func newTestPool(t *testing.T) *db.Pool {
|
||||
t.Helper()
|
||||
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
|
||||
if baseURL == "" {
|
||||
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
admin, err := pgx.Connect(ctx, baseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect admin: %v", err)
|
||||
}
|
||||
dbName := fmt.Sprintf("oikos_mcp_test_%08x", rand.Int63())
|
||||
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
|
||||
admin.Close(ctx)
|
||||
t.Fatalf("create test db: %v", err)
|
||||
}
|
||||
admin.Close(ctx)
|
||||
|
||||
qi := strings.Index(baseURL, "?")
|
||||
base, params := baseURL, ""
|
||||
if qi >= 0 {
|
||||
base, params = baseURL[:qi], baseURL[qi:]
|
||||
}
|
||||
testURL := base[:strings.LastIndex(base, "/")+1] + dbName + params
|
||||
|
||||
pool, err := db.New(ctx, testURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect test db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
pool.Close()
|
||||
if admin, e := pgx.Connect(ctx, baseURL); e == nil {
|
||||
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
|
||||
admin.Close(ctx)
|
||||
}
|
||||
})
|
||||
|
||||
if err := pool.Migrate(ctx); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
|
||||
content, err := os.ReadFile("../../seeds/" + f)
|
||||
if err != nil {
|
||||
t.Fatalf("read seed %s: %v", f, err)
|
||||
}
|
||||
name := f
|
||||
if err := pool.SeedIngest(ctx, name, content,
|
||||
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||
var err error
|
||||
switch name {
|
||||
case "ontology.yaml":
|
||||
_, err = db.IngestOntologySeed(ctx, tx, data)
|
||||
case "inventory.yaml":
|
||||
_, err = db.IngestInventorySeed(ctx, tx, data)
|
||||
case "policy.yaml":
|
||||
_, err = db.IngestPolicySeed(ctx, tx, data)
|
||||
}
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatalf("ingest %s: %v", f, err)
|
||||
}
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// callTool invokes a registered tool's handler in-process and returns its
|
||||
// concatenated text result.
|
||||
func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) string {
|
||||
t.Helper()
|
||||
var handler toolHandler
|
||||
for _, r := range allTools(pool, uuid.Nil) {
|
||||
if r.tool.Name == name {
|
||||
handler = r.handler
|
||||
break
|
||||
}
|
||||
}
|
||||
if handler == nil {
|
||||
t.Fatalf("tool %q not registered", name)
|
||||
}
|
||||
argsJSON, _ := json.Marshal(args)
|
||||
res, err := handler(context.Background(), &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{
|
||||
Name: name,
|
||||
Arguments: argsJSON,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("tool %s returned error: %v", name, err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, c := range res.Content {
|
||||
if tc, ok := c.(*mcp.TextContent); ok {
|
||||
sb.WriteString(tc.Text)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// checkCountFor returns the number of derived check_defs targeting slug.
|
||||
func checkCountFor(t *testing.T, pool *db.Pool, slug string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
err := pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM check_defs cd
|
||||
JOIN entities e ON e.id = cd.target_id
|
||||
WHERE e.slug = $1`, slug).Scan(&n)
|
||||
if err != nil {
|
||||
t.Fatalf("count check_defs for %s: %v", slug, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestCreateEntity_DerivesChecks proves create_entity inserts an entity AND
|
||||
// derives its default checks in one call (the HTTP create path did this; the
|
||||
// MCP path previously could not create at all).
|
||||
func TestCreateEntity_DerivesChecks(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
slug := "service:mcp-create-test"
|
||||
|
||||
out := callTool(t, pool, "create_entity", map[string]any{
|
||||
"type": "service",
|
||||
"slug": slug,
|
||||
"name": "mcp-create-test",
|
||||
"attributes": `{"url":"https://mcp-create-test.example"}`,
|
||||
})
|
||||
if !strings.Contains(out, "Created "+slug) {
|
||||
t.Fatalf("create_entity result = %q, want Created %s", out, slug)
|
||||
}
|
||||
if !strings.Contains(out, "Derived") {
|
||||
t.Errorf("create_entity result = %q, want a Derived check summary", out)
|
||||
}
|
||||
if got := checkCountFor(t, pool, slug); got < 1 {
|
||||
t.Errorf("check_defs targeting %s = %d, want >=1 (create did not derive checks)", slug, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateEntity_DuplicateAndInvalid covers the guard rails: a repeat create
|
||||
// is reported as "already exists" (not an error), and an unknown type is
|
||||
// rejected with a clear message.
|
||||
func TestCreateEntity_DuplicateAndInvalid(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
|
||||
if out := callTool(t, pool, "create_entity", map[string]any{
|
||||
"type": "service", "slug": "service:mcp-dup", "name": "mcp-dup",
|
||||
}); !strings.Contains(out, "Created service:mcp-dup") {
|
||||
t.Fatalf("first create = %q", out)
|
||||
}
|
||||
if out := callTool(t, pool, "create_entity", map[string]any{
|
||||
"type": "service", "slug": "service:mcp-dup", "name": "mcp-dup",
|
||||
}); !strings.Contains(out, "already exists") {
|
||||
t.Errorf("duplicate create = %q, want 'already exists'", out)
|
||||
}
|
||||
if out := callTool(t, pool, "create_entity", map[string]any{
|
||||
"type": "no-such-type", "slug": "no-such-type:x", "name": "x",
|
||||
}); !strings.Contains(out, "not found in ontology") {
|
||||
t.Errorf("unknown type = %q, want 'not found in ontology'", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateEntityAttributes_RegeneratesChecks is the regression guard for the
|
||||
// haos session: setting an entity's `monitoring` attribute via MCP must
|
||||
// regenerate checks. Before this fix the MCP update path skipped
|
||||
// ensureDefaultChecks, so flipping monitoring produced nothing.
|
||||
func TestUpdateEntityAttributes_RegeneratesChecks(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
slug := "service:mcp-regen-test"
|
||||
|
||||
// Create with monitoring:none — no checks derived.
|
||||
if out := callTool(t, pool, "create_entity", map[string]any{
|
||||
"type": "service", "slug": slug, "name": "mcp-regen-test",
|
||||
"attributes": `{"monitoring":"none","url":"https://mcp-regen.example"}`,
|
||||
}); !strings.Contains(out, "Created "+slug) {
|
||||
t.Fatalf("create = %q", out)
|
||||
}
|
||||
if got := checkCountFor(t, pool, slug); got != 0 {
|
||||
t.Fatalf("check_defs with monitoring:none = %d, want 0", got)
|
||||
}
|
||||
|
||||
// Flip monitoring to [http] via update_entity_attributes — checks must
|
||||
// regenerate. This is exactly what failed for service:haos.
|
||||
out := callTool(t, pool, "update_entity_attributes", map[string]any{
|
||||
"slug": slug,
|
||||
"attributes": `{"monitoring":["http"]}`,
|
||||
})
|
||||
if !strings.Contains(out, "Updated "+slug) {
|
||||
t.Fatalf("update result = %q, want Updated %s", out, slug)
|
||||
}
|
||||
if !strings.Contains(out, "Derived") {
|
||||
t.Errorf("update result = %q, want a Derived check summary (regeneration)", out)
|
||||
}
|
||||
if got := checkCountFor(t, pool, slug); got < 1 {
|
||||
t.Errorf("check_defs after monitoring:[http] = %d, want >=1 (MCP update did not regenerate checks)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateEntityAttributes_NotFound keeps the existing error contract.
|
||||
func TestUpdateEntityAttributes_NotFound(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
out := callTool(t, pool, "update_entity_attributes", map[string]any{
|
||||
"slug": "service:does-not-exist",
|
||||
"attributes": `{"x":1}`,
|
||||
})
|
||||
if !strings.Contains(out, "not found") {
|
||||
t.Errorf("update missing entity = %q, want 'not found'", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatCheckResult is a pure unit test for the result-message helper, so
|
||||
// the formatting contract holds even when the DB is unavailable.
|
||||
func TestFormatCheckResult(t *testing.T) {
|
||||
if got := formatCheckResult(checkdefaults.Result{Created: 2}); !strings.Contains(got, "Derived 2 check") {
|
||||
t.Errorf("created-only = %q, want Derived 2", got)
|
||||
}
|
||||
got := formatCheckResult(checkdefaults.Result{Created: 1, Skipped: []checkdefaults.Skip{{Kind: "process", Reason: "no host"}}})
|
||||
if !strings.Contains(got, "Derived 1 check") || !strings.Contains(got, "Skipped process") || !strings.Contains(got, "no host") {
|
||||
t.Errorf("created+skipped = %q", got)
|
||||
}
|
||||
if got := formatCheckResult(checkdefaults.Result{Undeclared: true}); !strings.Contains(got, "no monitoring") {
|
||||
t.Errorf("undeclared = %q, want no-monitoring hint", got)
|
||||
}
|
||||
if formatCreateResult("a", "b", checkdefaults.Result{Created: 0}) != "Created a (b)." {
|
||||
t.Error("create result with no checks should have no suffix")
|
||||
}
|
||||
}
|
||||
@@ -72,9 +72,121 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
for _, t := range allTools(pool, agentID) {
|
||||
s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler))
|
||||
}
|
||||
|
||||
// Resource templates: let MCP clients browse and attach entities,
|
||||
// knowledge entries, and executions as conversation resources.
|
||||
s.AddResourceTemplate(&mcp.ResourceTemplate{
|
||||
URITemplate: "oikos://entity/{slug}",
|
||||
Name: "Entity",
|
||||
Description: "Oikos entity by slug (e.g. host:hubris, lxc:jellyfin)",
|
||||
MIMEType: "application/json",
|
||||
}, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) {
|
||||
slug := matches["slug"]
|
||||
var id uuid.UUID
|
||||
if u, err := uuid.Parse(slug); err == nil {
|
||||
id = u
|
||||
} else {
|
||||
pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id)
|
||||
}
|
||||
if id == uuid.Nil {
|
||||
return "", fmt.Errorf("entity not found: %s", slug)
|
||||
}
|
||||
result := queryEntity(ctx, pool, slug)
|
||||
return result.Content[0].(*mcp.TextContent).Text, nil
|
||||
}))
|
||||
|
||||
s.AddResourceTemplate(&mcp.ResourceTemplate{
|
||||
URITemplate: "oikos://knowledge/{id}",
|
||||
Name: "Knowledge",
|
||||
Description: "Knowledge entry by entity slug or UUID",
|
||||
MIMEType: "application/json",
|
||||
}, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) {
|
||||
idOrSlug := matches["id"]
|
||||
var entityID uuid.UUID
|
||||
if u, err := uuid.Parse(idOrSlug); err == nil {
|
||||
entityID = u
|
||||
} else {
|
||||
pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&entityID)
|
||||
}
|
||||
if entityID == uuid.Nil {
|
||||
return "", fmt.Errorf("knowledge not found: %s", idOrSlug)
|
||||
}
|
||||
result := queryRows(ctx, pool, `
|
||||
SELECT ke.title, ke.content, ke.tags::text, e.slug, e.type AS kind,
|
||||
ke.updated_at::text
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.entity_id = $1`, entityID)
|
||||
return result.Content[0].(*mcp.TextContent).Text, nil
|
||||
}))
|
||||
|
||||
s.AddResourceTemplate(&mcp.ResourceTemplate{
|
||||
URITemplate: "oikos://execution/{id}",
|
||||
Name: "Execution",
|
||||
Description: "Execution by UUID (returns status, result, timing)",
|
||||
MIMEType: "application/json",
|
||||
}, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) {
|
||||
result := queryRows(ctx, pool, `
|
||||
SELECT e.entity_id, te.slug AS target, e.action, e.risk_class,
|
||||
e.status, e.result::text, e.duration_ms,
|
||||
e.started_at::text, e.completed_at::text
|
||||
FROM executions e
|
||||
JOIN entities te ON te.id = e.target_entity_id
|
||||
WHERE e.entity_id = $1`, matches["id"])
|
||||
return result.Content[0].(*mcp.TextContent).Text, nil
|
||||
}))
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// resourceHandler adapts a simple func(ctx, params) → (string, error) into
|
||||
// an MCP ResourceHandler, reading the URI matched by a ResourceTemplate.
|
||||
func resourceHandler(pool *db.Pool, fn func(ctx context.Context, matches map[string]string) (string, error)) mcp.ResourceHandler {
|
||||
return func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
|
||||
uri := req.Params.URI
|
||||
matches := matchURITemplate(uri)
|
||||
if matches == nil {
|
||||
return nil, mcp.ResourceNotFoundError(uri)
|
||||
}
|
||||
|
||||
text, err := fn(ctx, matches)
|
||||
if err != nil {
|
||||
return nil, mcp.ResourceNotFoundError(uri)
|
||||
}
|
||||
|
||||
result, err := json.MarshalIndent(json.RawMessage(text), "", " ")
|
||||
if err != nil {
|
||||
result = []byte(text)
|
||||
}
|
||||
|
||||
return &mcp.ReadResourceResult{
|
||||
Contents: []*mcp.ResourceContents{{
|
||||
URI: uri,
|
||||
MIMEType: "application/json",
|
||||
Text: string(result),
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// matchURITemplate extracts parameters from a URI that matches one of the
|
||||
// oikos:// resource templates. Returns nil if the URI doesn't match.
|
||||
func matchURITemplate(uri string) map[string]string {
|
||||
// oikos://entity/{slug}
|
||||
if rest, ok := strings.CutPrefix(uri, "oikos://entity/"); ok && rest != "" {
|
||||
return map[string]string{"slug": rest}
|
||||
}
|
||||
// oikos://knowledge/{id}
|
||||
if rest, ok := strings.CutPrefix(uri, "oikos://knowledge/"); ok && rest != "" {
|
||||
return map[string]string{"id": rest}
|
||||
}
|
||||
// oikos://execution/{id}
|
||||
if rest, ok := strings.CutPrefix(uri, "oikos://execution/"); ok && rest != "" {
|
||||
return map[string]string{"id": rest}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withActivityLogging wraps a tool handler to record agent_activity rows.
|
||||
func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next mcp.ToolHandler) mcp.ToolHandler {
|
||||
if agentID == uuid.Nil {
|
||||
@@ -628,8 +740,106 @@ func autoRun(ctx context.Context, pool *db.Pool, id uuid.UUID, targetSlug, comma
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// autoRunAsync starts a command in a goroutine, marking it running and returning
|
||||
// immediately. The caller gets an execution_id to poll with get_execution_status.
|
||||
// Used for commands containing sleep/wait/poll loops that would exceed the MCP
|
||||
// client timeout (120s) — the execution continues server-side.
|
||||
func autoRunAsync(ctx context.Context, pool *db.Pool, id uuid.UUID, targetSlug, command string) {
|
||||
startedAt := time.Now()
|
||||
if _, err := pool.Exec(ctx,
|
||||
`UPDATE executions SET status='running', started_at=$2 WHERE entity_id=$1`,
|
||||
id, startedAt); err != nil {
|
||||
slog.Error("mcp: mark execution running (async)", "error", err, "execution_id", id)
|
||||
}
|
||||
|
||||
host, user, wrap, err := resolveExecTarget(ctx, pool, targetSlug)
|
||||
if err != nil {
|
||||
pool.Exec(ctx,
|
||||
`UPDATE executions SET status='failed', result=$2::jsonb, duration_ms=$3, completed_at=now() WHERE entity_id=$1`,
|
||||
id, jsonErr("%s", err.Error()), int(time.Since(startedAt).Milliseconds()))
|
||||
slog.Error("mcp: async run resolve target", "error", err, "execution_id", id, "target", targetSlug)
|
||||
return
|
||||
}
|
||||
|
||||
var correlationID string
|
||||
if qerr := pool.QueryRow(ctx,
|
||||
`SELECT correlation_id FROM executions WHERE entity_id = $1`, id).Scan(&correlationID); qerr != nil {
|
||||
correlationID = ""
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("mcp: async run panic", "panic", r, "execution_id", id)
|
||||
pool.Exec(context.Background(),
|
||||
`UPDATE executions SET status='failed', result=$2::jsonb, duration_ms=$3, completed_at=now() WHERE entity_id=$1`,
|
||||
id, jsonErr("panic: %v", r), int(time.Since(startedAt).Milliseconds()))
|
||||
}
|
||||
}()
|
||||
|
||||
sink, flush := execlog.New(context.Background(), pool, id, correlationID)
|
||||
out, execErr := sshExecStream(context.Background(), host, user, wrap(command), sink)
|
||||
flush()
|
||||
if execErr != nil {
|
||||
pool.Exec(context.Background(),
|
||||
`UPDATE executions SET status='failed', result=$2::jsonb, duration_ms=$3, completed_at=now() WHERE entity_id=$1`,
|
||||
id, jsonErr("%s: %s", execErr.Error(), out), int(time.Since(startedAt).Milliseconds()))
|
||||
slog.Error("mcp: async run failed", "error", execErr, "execution_id", id, "output", out)
|
||||
} else {
|
||||
pool.Exec(context.Background(),
|
||||
`UPDATE executions SET status='completed', result=$2::jsonb, duration_ms=$3, completed_at=now() WHERE entity_id=$1`,
|
||||
id, jsonOut(out), int(time.Since(startedAt).Milliseconds()))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// isLongRunningCommand detects shell commands containing sleep, wait, or poll
|
||||
// loops that indicate the command will exceed the MCP client timeout (120s).
|
||||
// These commands should use autoRunAsync to avoid the client timing out while
|
||||
// the command continues server-side.
|
||||
func isLongRunningCommand(cmd string) bool {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
|
||||
// sleep with duration — `sleep 30`, `sleep 1m`, etc.
|
||||
if sleepRe.MatchString(cmd) {
|
||||
return true
|
||||
}
|
||||
|
||||
// while/shell poll loops with sleep: `while ...; do ... sleep; done`
|
||||
if pollRe.MatchString(cmd) {
|
||||
return true
|
||||
}
|
||||
|
||||
// standalone wait command
|
||||
if waitRe.MatchString(cmd) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
sleepRe = regexp.MustCompile(`\bsleep\s+\d`)
|
||||
pollRe = regexp.MustCompile(`\bwhile\b.*\bsleep\b`)
|
||||
waitRe = regexp.MustCompile(`\bwait\s+\d|[&;]\s*wait\b`)
|
||||
)
|
||||
|
||||
func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk, sessionID string) *mcp.CallToolResult {
|
||||
riskClass := policy.ClassifyCommand(command, declaredRisk)
|
||||
|
||||
// Transport-aware escalation: read-only commands on LXC targets that
|
||||
// touch config paths (/opt/, /etc/) escalate to config_mutation.
|
||||
// The classifier only scores the command text, not the transport layer
|
||||
// — SSH-ing into a container to read /opt/ is riskier than running
|
||||
// the same command locally on the Proxmox host via pct exec.
|
||||
// Caught live: "cat /etc/hostname" on lxc:dns queued as config_mutation
|
||||
// while "pct exec 107 -- cat /etc/hostname" on host:hubris auto-ran.
|
||||
if riskClass == policy.RiskReadOnly && strings.HasPrefix(targetSlug, "lxc:") {
|
||||
if strings.Contains(command, "/opt/") || strings.Contains(command, "/etc/") || strings.Contains(command, "/var/lib/") {
|
||||
riskClass = policy.RiskConfigMutation
|
||||
}
|
||||
}
|
||||
|
||||
runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
|
||||
actionCol := "run:" + string(runParams)
|
||||
|
||||
@@ -647,6 +857,57 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
||||
return textResult("No plan for this session. Call set_goal then propose_plan before run — even read-only tasks require a one-step plan. A one-step plan (\"Inspect X, report, write back\") is fine for trivial questions; the gate is about ordering, not approval. Read-only commands still auto-execute once a plan exists.")
|
||||
}
|
||||
|
||||
// Target validation: host-only commands (qm, pct, pvesh, iptables) must
|
||||
// not be dispatched against lxc:/vm: targets — those aren't Proxmox hosts
|
||||
// and don't have these tools. Caught live 2026-08-04: the agent ran
|
||||
// `qm stop 100` against lxc:dns, wasting a turn.
|
||||
|
||||
// Command syntax validation: catch LLM-generated bash bugs before they
|
||||
// hit the shell. The model sometimes inserts literal \n between commands
|
||||
// or puts spaces inside flags — these always fail, so reject early.
|
||||
if syntaxErr := validateCommandSyntax(command); syntaxErr != "" {
|
||||
return textResult(syntaxErr)
|
||||
}
|
||||
|
||||
if cmdPrefix, hostOnly := hostOnlyCommand(command); hostOnly && !strings.HasPrefix(targetSlug, "host:") {
|
||||
hostSuggestion := resolveProxmoxHostSlug(ctx, pool, targetSlug, "")
|
||||
if hostSuggestion == "" {
|
||||
hostSuggestion = "host:hubris or host:strong"
|
||||
}
|
||||
return textResult(fmt.Sprintf("Cannot run %q on %s — %s is a Proxmox host command. Use target %s instead.",
|
||||
cmdPrefix, targetSlug, cmdPrefix, hostSuggestion))
|
||||
}
|
||||
|
||||
// systemctl and docker work on hosts and LXCs, but not VMs.
|
||||
if cmdPrefix, hostLxc := hostLxcCommand(command); hostLxc {
|
||||
if !strings.HasPrefix(targetSlug, "host:") && !strings.HasPrefix(targetSlug, "lxc:") {
|
||||
return textResult(fmt.Sprintf("Cannot run %q on %s — %s only works on host:* or lxc:* targets.",
|
||||
cmdPrefix, targetSlug, cmdPrefix))
|
||||
}
|
||||
}
|
||||
|
||||
// VM transport pre-flight: qm guest exec requires the QEMU guest agent
|
||||
// to be running inside the VM. If it's not, the execution would queue
|
||||
// for approval and never execute — the agent has no way to learn it's
|
||||
// stuck (spotted live 2026-08-05: vm:zimaos had qemu_guest_agent=not_running,
|
||||
// the run queued forever, and the agent fell back to unsafe raw SSH).
|
||||
if strings.HasPrefix(targetSlug, "vm:") {
|
||||
var rawAttrs []byte
|
||||
if err := pool.QueryRow(ctx, `SELECT attributes FROM entities WHERE id = $1`, targetID).Scan(&rawAttrs); err == nil {
|
||||
var attrs map[string]any
|
||||
if json.Unmarshal(rawAttrs, &attrs) == nil {
|
||||
if qga, ok := attrs["qemu_guest_agent"]; ok {
|
||||
qgaStr, _ := qga.(string)
|
||||
if qgaStr == "not_running" || qgaStr == "" {
|
||||
return textResult(fmt.Sprintf(
|
||||
"run on %s blocked: QEMU guest agent is not running (%s). qm guest exec cannot reach this VM. Start the agent inside the guest first (e.g. via SSH/systemctl start qemu-guest-agent), then re-run. If the agent is running but the entity attribute is stale, update it with update_entity_attributes(slug=%s, attributes={\"qemu_guest_agent\":\"running\"}).",
|
||||
targetSlug, qgaStr, targetSlug))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup: an identical pending command (same target, command, and
|
||||
// purpose) blocks a re-request — stops a tool-calling loop from queuing
|
||||
// the same approval repeatedly.
|
||||
@@ -721,8 +982,51 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
|
||||
id, "task:"+sessionID)
|
||||
// Link execution to session for auto-continuation (nomos_plan_executions
|
||||
// was always empty — executions were never traceable back to sessions).
|
||||
if sid, serr := uuid.Parse(sessionID); serr == nil {
|
||||
pool.Exec(ctx, `
|
||||
INSERT INTO nomos_plan_executions (execution_id, session_id)
|
||||
VALUES ($1, $2) ON CONFLICT (execution_id) DO NOTHING`, id, sid)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-classify: write the classification decision to the classifications
|
||||
// table (was always empty — 0 rows despite 1,884 executions). The route
|
||||
// matches the auto-run vs queue-for-approval decision below.
|
||||
classRoute := "escalate"
|
||||
if riskClass == policy.RiskReadOnly || riskClass == policy.RiskReversibleLow {
|
||||
classRoute = "auto-act"
|
||||
} else if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||
classRoute = "auto-act"
|
||||
} else if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug, sessionID) {
|
||||
classRoute = "auto-act"
|
||||
}
|
||||
classReason, _ := json.Marshal(map[string]string{
|
||||
"command": command, "purpose": purpose, "target": targetSlug, "declared_risk": declaredRisk,
|
||||
})
|
||||
classID, _ := uuid.NewV7()
|
||||
pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'classification', $3, '{}')`,
|
||||
classID, "classification:"+classID.String(), "classification for "+execSlug)
|
||||
pool.Exec(ctx, `INSERT INTO classifications (entity_id, action, risk_class, route, reasoning, correlation_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
classID, actionCol, riskClass, classRoute, classReason, correlationID)
|
||||
// Link classification to execution.
|
||||
pool.Exec(ctx, `UPDATE executions SET classification_id = $2 WHERE entity_id = $1`, id, classID)
|
||||
|
||||
// Audit: record the execution creation with session_id for traceability.
|
||||
// Every run call, whether auto-run or queued-for-approval, gets an audit
|
||||
// entry so the agent's activity is traceable back to the originating session.
|
||||
var auditSessionID *uuid.UUID
|
||||
if sessionID != "" && sessionID != "ephemeral" {
|
||||
if sid, serr := uuid.Parse(sessionID); serr == nil {
|
||||
auditSessionID = &sid
|
||||
}
|
||||
}
|
||||
_ = observability.Audit(ctx, sqlcgen.New(pool), "agent", "nomos", "run",
|
||||
&id, "POST", "/mcp", correlationID, auditSessionID,
|
||||
map[string]any{"command": command, "target": targetSlug, "risk_class": riskClass, "purpose": purpose})
|
||||
|
||||
// read_only and reversible_low both run unattended, as seeds/policy.yaml
|
||||
// and .agents/OIKOS.md declare ("reversible_low — restart, cache clear,
|
||||
// sync pull. Unattended + ledger.").
|
||||
@@ -741,6 +1045,11 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
||||
// agent still cannot talk a command DOWN: declaring reversible_low on
|
||||
// something computed as config_mutation keeps config_mutation.
|
||||
if riskClass == policy.RiskReadOnly || riskClass == policy.RiskReversibleLow {
|
||||
if isLongRunningCommand(command) {
|
||||
autoRunAsync(ctx, pool, id, targetSlug, command)
|
||||
return textResult(fmt.Sprintf("run on %s (%s, async): started — execution %s. Poll with get_execution_status(%s) for result.",
|
||||
targetSlug, riskClass, id, id))
|
||||
}
|
||||
out, xerr := autoRun(ctx, pool, id, targetSlug, command)
|
||||
if xerr != nil {
|
||||
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
|
||||
@@ -759,6 +1068,12 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
||||
// consent. The assent window, opened only on operator approval, is the
|
||||
// sole gate for config_mutation auto-run.)
|
||||
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||
if isLongRunningCommand(command) {
|
||||
autoRunAsync(ctx, pool, id, targetSlug, command)
|
||||
slog.Info("mcp: run async via assent window", "target", targetSlug, "execution_id", id)
|
||||
return textResult(fmt.Sprintf("run on %s (config_mutation, async via assent window): started — execution %s. Poll with get_execution_status(%s) for result.",
|
||||
targetSlug, id, id))
|
||||
}
|
||||
out, xerr := autoRun(ctx, pool, id, targetSlug, command)
|
||||
if xerr != nil {
|
||||
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
|
||||
@@ -774,6 +1089,12 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
||||
// operator isn't asked to re-type "I confirm" for every single command
|
||||
// against the thing they just confirmed.
|
||||
if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug, sessionID) {
|
||||
if isLongRunningCommand(command) {
|
||||
autoRunAsync(ctx, pool, id, targetSlug, command)
|
||||
slog.Info("mcp: run async via destructive window", "target", targetSlug, "execution_id", id)
|
||||
return textResult(fmt.Sprintf("run on %s (destructive, async via confirmed-target window): started — execution %s. Poll with get_execution_status(%s) for result.",
|
||||
targetSlug, id, id))
|
||||
}
|
||||
out, xerr := autoRun(ctx, pool, id, targetSlug, command)
|
||||
if xerr != nil {
|
||||
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
|
||||
@@ -840,22 +1161,112 @@ func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, ac
|
||||
// for config_mutation auto-run now. See sessionHasPlan for the plan-existence
|
||||
// check used by the P1 plan-first gate.
|
||||
|
||||
// hostOnlyCommands maps command prefixes that are only valid on Proxmox host
|
||||
// targets (not LXCs or VMs). Running these against an lxc: or vm: target
|
||||
// always fails with "command not found" and wastes a turn.
|
||||
var hostOnlyCommands = map[string]bool{
|
||||
"qm": true,
|
||||
"pct": true,
|
||||
"pvesh": true,
|
||||
"iptables": true,
|
||||
}
|
||||
|
||||
// hostLxcCommands maps command prefixes valid on host:* and lxc:* but not vm:*.
|
||||
var hostLxcCommands = map[string]bool{
|
||||
"systemctl": true,
|
||||
"docker": true,
|
||||
}
|
||||
|
||||
// hostOnlyCommand checks whether the leading word of cmd is a host-only
|
||||
// command. Returns the command word and true if the command can only run on
|
||||
// a host: target.
|
||||
func hostOnlyCommand(cmd string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(cmd)
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) == 0 {
|
||||
return "", false
|
||||
}
|
||||
first := parts[0]
|
||||
// Check for shell wrappers: bash -c 'actual_cmd', sh -c 'actual_cmd'
|
||||
if (first == "bash" || first == "sh") && len(parts) >= 3 && parts[1] == "-c" {
|
||||
// The actual command is inside the -c argument; extract the first word.
|
||||
// This handles `bash -c 'qm stop 100'` but not deeply nested wrappers.
|
||||
actual := strings.Trim(strings.Join(parts[2:], " "), "'\"")
|
||||
if inner := strings.Fields(actual); len(inner) > 0 {
|
||||
first = inner[0]
|
||||
}
|
||||
}
|
||||
// Strip path: /usr/sbin/qm → qm
|
||||
if idx := strings.LastIndexByte(first, '/'); idx >= 0 {
|
||||
first = first[idx+1:]
|
||||
}
|
||||
return first, hostOnlyCommands[first]
|
||||
}
|
||||
|
||||
// hostLxcCommand checks whether the leading word of cmd is a command valid on
|
||||
// host:* and lxc:* targets but not vm:*. Returns the command word and true if
|
||||
// the command is restricted to host/lxc.
|
||||
func hostLxcCommand(cmd string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(cmd)
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) == 0 {
|
||||
return "", false
|
||||
}
|
||||
first := parts[0]
|
||||
if idx := strings.LastIndexByte(first, '/'); idx >= 0 {
|
||||
first = first[idx+1:]
|
||||
}
|
||||
return first, hostLxcCommands[first]
|
||||
}
|
||||
|
||||
// validateCommandSyntax checks for common LLM-generated bash errors that always
|
||||
// fail at the shell. Returns an error message or "" if the command looks valid.
|
||||
func validateCommandSyntax(cmd string) string {
|
||||
// Reject literal \n (the LLM sometimes writes `echo "---" && \n curl ...`
|
||||
// — the \n is literal in the command string, not an actual newline).
|
||||
if strings.Contains(cmd, "\\n") {
|
||||
return fmt.Sprintf("Command contains literal '\\n' — use ';' or '&&' between commands, not a literal backslash-n. Command: %q", cmd)
|
||||
}
|
||||
|
||||
// Reject `&& \n` patterns (the LLM writes `cmd1 && \n cmd2` — the \n is
|
||||
// a literal newline that bash interprets as a command separator, but the
|
||||
// leading backslash makes it a syntax error).
|
||||
if andBackslashRe.MatchString(cmd) {
|
||||
return fmt.Sprintf("Command contains '&&' followed by a literal backslash-newline — remove the backslash or use ';' instead. Command: %q", cmd)
|
||||
}
|
||||
|
||||
// Reject `\` at end of command with no continuation (last line ends with
|
||||
// backslash but there's nothing after it).
|
||||
trimmed := strings.TrimSpace(cmd)
|
||||
if strings.HasSuffix(trimmed, "\\") {
|
||||
return fmt.Sprintf("Command ends with a backslash but has nothing after it to continue. Remove the trailing '\\'. Command: %q", cmd)
|
||||
}
|
||||
|
||||
// Warn on common flag typos: `head - n`, `grep - i`, `tail - n`, etc.
|
||||
// These are space-between-flag-and-value errors the LLM produces.
|
||||
if flagSpaceRe.MatchString(cmd) {
|
||||
return fmt.Sprintf("Command has a space between a flag and its value (e.g. 'head - n' instead of 'head -n'). Remove the space. Command: %q", cmd)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
var andBackslashRe = regexp.MustCompile(`&&\s*\\\s*\n`)
|
||||
var flagSpaceRe = regexp.MustCompile(`\b(head|tail|grep|sed|awk|sort|uniq|wc)\s+(-\w)\s+\w`)
|
||||
|
||||
// sessionHasPlan reports whether this nomos session has any plan step on
|
||||
// record (any generation, any status). Used by the P1 plan-first gate in
|
||||
// classifyAndGate to refuse `run` before `propose_plan` has been called.
|
||||
// A `replaced` step (from a prior plan generation that was superseded by a
|
||||
// follow-up sub-task — see store.reopenSession) still counts: it proves the
|
||||
// agent once framed a plan for this session, and the reopen path guarantees a
|
||||
// fresh `propose_plan` will run before the next `run` anyway. Fails closed
|
||||
// (returns true) when the query errors so a transient DB issue doesn't block
|
||||
// an otherwise-valid run.
|
||||
// record that isn't `replaced`. Replaced steps (from session reopen via
|
||||
// store.reopenSession) don't count — the agent must propose fresh plan before
|
||||
// any `run`. Fails closed (returns true) when the query errors so a transient
|
||||
// DB issue doesn't block an otherwise-valid run.
|
||||
func sessionHasPlan(ctx context.Context, pool *db.Pool, sessionID string) bool {
|
||||
if sessionID == "" {
|
||||
return true // no session → no gate (direct MCP call from a script)
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM session_plan_steps WHERE session_id = $1`,
|
||||
`SELECT COUNT(*) FROM session_plan_steps
|
||||
WHERE session_id = $1 AND status <> 'replaced'`,
|
||||
sessionID).Scan(&count); err != nil {
|
||||
return true // fail open on DB error — don't block work over a flake
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ import (
|
||||
// starts being populated when OIDC identity resolution lands.
|
||||
func Audit(ctx context.Context, q *sqlcgen.Queries, actorType, actorLabel,
|
||||
action string, entityID *uuid.UUID, method, path, correlationID string,
|
||||
detail map[string]any) error {
|
||||
sessionID *uuid.UUID, detail map[string]any) error {
|
||||
|
||||
if detail == nil {
|
||||
detail = map[string]any{}
|
||||
@@ -36,6 +36,7 @@ func Audit(ctx context.Context, q *sqlcgen.Queries, actorType, actorLabel,
|
||||
Path: &path,
|
||||
Detail: detailJSON,
|
||||
CorrelationID: corr,
|
||||
SessionID: sessionID,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,15 @@ var curlLeadRe = regexp.MustCompile(`(?i)^curl\b`)
|
||||
// When any of these appears, the curl command is no longer read-only.
|
||||
var curlMutateRe = regexp.MustCompile(`(?i)(?:^|\s)-X\s+(?:post|put|delete|patch|connect|trace)\b|(?:^|\s)-(?:d|F|T|o)\b|(?:^|\s)--(?:data[-a-z]*|request|form|upload-file|output)\b`)
|
||||
|
||||
// curlDevNullOutRe matches curl output redirected to /dev/null in any of curl's
|
||||
// argument forms (space, =, or attached). /dev/null is a no-op sink, so a GET
|
||||
// that discards its body — the canonical reachability idiom
|
||||
// `curl -o /dev/null -w '%{http_code}' URL` — is read-only. Output to any real
|
||||
// path (-o /tmp/x) stays a potential mutation. Stripped before curlMutateRe so
|
||||
// the remaining flags (-X, -d, ...) still classify correctly: a
|
||||
// `curl -o /dev/null -X POST` stays config_mutation.
|
||||
var curlDevNullOutRe = regexp.MustCompile(`(?i)(^|\s)-o\s*/dev/null(\s|$)|(^|\s)--output[=\s]\s*/dev/null(\s|$)`)
|
||||
|
||||
// redirectOutRe matches shell output redirection to a file (> or >> followed
|
||||
// by a path), but excludes the file-descriptor merge form `>&<digit>` (e.g.
|
||||
// `2>&1`) which only rearranges streams and writes nothing to disk. RE2 has
|
||||
@@ -308,6 +317,10 @@ func curlIsReadOnly(curlCmd string) bool {
|
||||
if !curlLeadRe.MatchString(curlCmd) {
|
||||
return false
|
||||
}
|
||||
// -o /dev/null is a no-op sink: strip it before flag detection so the
|
||||
// canonical GET-and-discard reachability probe stays read-only.
|
||||
// A `curl -o /dev/null -X POST` still fails curlMutateRe after stripping.
|
||||
curlCmd = curlDevNullOutRe.ReplaceAllString(curlCmd, " ")
|
||||
if curlMutateRe.MatchString(curlCmd) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -102,6 +102,33 @@ func TestClassifyCommand_CurlPipeSh_ConfigMutation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyCommand_CurlDevNull_ReadOnly(t *testing.T) {
|
||||
// -o /dev/null is a no-op sink — the canonical GET-and-discard
|
||||
// reachability idiom must stay read_only. Output to real paths stays
|
||||
// config_mutation. POST/data flags after stripping still gate.
|
||||
cases := []struct {
|
||||
cmd string
|
||||
cls string
|
||||
}{
|
||||
// read_only: GET with body discarded to /dev/null
|
||||
{`curl -o /dev/null -w '%{http_code}' --connect-timeout 10 http://192.168.8.101:8123`, RiskReadOnly},
|
||||
{`curl -sS -o /dev/null https://home.hubris.network`, RiskReadOnly},
|
||||
{`curl --output /dev/null https://example.com`, RiskReadOnly},
|
||||
{`curl -o /dev/null https://example.com`, RiskReadOnly},
|
||||
{`curl -o/dev/null -w '%{http_code}' https://example.com`, RiskReadOnly},
|
||||
// config_mutation: POST/data still caught after stripping devnull
|
||||
{`curl -o /dev/null -X POST https://example.com`, RiskConfigMutation},
|
||||
{`curl -o /dev/null -d '{"x":1}' https://example.com`, RiskConfigMutation},
|
||||
// config_mutation: -o to real path stays config_mutation
|
||||
{`curl -o /etc/caddy/Caddyfile http://example.com`, RiskConfigMutation},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyCommand(c.cmd, ""); got != c.cls {
|
||||
t.Errorf("ClassifyCommand(%q) = %q, want %q", c.cmd, got, c.cls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyCommand_DefaultEscalatesToConfigMutation(t *testing.T) {
|
||||
cases := []string{
|
||||
"apt-get install -y nginx",
|
||||
|
||||
@@ -299,6 +299,8 @@ func executeCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledChec
|
||||
return checkSSHScript(ctx, pool, cd)
|
||||
case "backup-freshness":
|
||||
return checkBackupFreshness(ctx, cd)
|
||||
case "dns":
|
||||
return checkDNS(ctx, cd)
|
||||
default:
|
||||
return checkResult{health: "unknown"}
|
||||
}
|
||||
@@ -484,6 +486,53 @@ func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResu
|
||||
return checkResult{health: "healthy"}
|
||||
}
|
||||
|
||||
// checkDNS verifies a DNS name resolves, catching a stale or unreachable
|
||||
// zone. It looks up NS records first (a zone always has NS), falling back to
|
||||
// an A/AAAA lookup for hostnames. Uses the system resolver; for split-horizon
|
||||
// correctness reserve an explicit `server` in the config.
|
||||
func checkDNS(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||
cfg := struct {
|
||||
Name string `json:"name"`
|
||||
Server string `json:"server"`
|
||||
}{}
|
||||
if len(cd.Config) > 0 {
|
||||
_ = json.Unmarshal(cd.Config, &cfg)
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
return checkResult{health: "healthy"}
|
||||
}
|
||||
|
||||
// Resolve via an explicit server when supplied (split-horizon), else the
|
||||
// system default resolver.
|
||||
lookup := func(q string) (int, error) {
|
||||
r := &net.Resolver{}
|
||||
if cfg.Server != "" {
|
||||
r = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: 5 * time.Second}
|
||||
return d.DialContext(ctx, network, net.JoinHostPort(cfg.Server, "53"))
|
||||
}}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
ns, err := r.LookupNS(ctx, q)
|
||||
if err == nil && len(ns) > 0 {
|
||||
return len(ns), nil
|
||||
}
|
||||
addrs, err2 := r.LookupHost(ctx, q)
|
||||
return len(addrs), err2
|
||||
}
|
||||
|
||||
n, err := lookup(cfg.Name)
|
||||
if err != nil || n == 0 {
|
||||
return checkResult{
|
||||
health: "down", signalKind: "dns",
|
||||
evidence: fmt.Sprintf("DNS resolution failed for %q: %v", cfg.Name, err),
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
return checkResult{health: "healthy"}
|
||||
}
|
||||
|
||||
// checkDisk performs a disk usage check.
|
||||
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||
cfg := struct {
|
||||
|
||||
8
migrations/030_plan_step_replaced_reason.up.sql
Normal file
8
migrations/030_plan_step_replaced_reason.up.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- 030_plan_step_replaced_reason.up.sql
|
||||
-- Add replaced_reason to session_plan_steps so the agent must explain why
|
||||
-- a step was replaced (wrong_diagnosis, scope_change, blocked, superseded,
|
||||
-- operator_override) rather than silently replacing entire plans. The column
|
||||
-- is also set by bulk-replace operations (proposePlan, setGoal, reopenSession)
|
||||
-- for auditability.
|
||||
|
||||
ALTER TABLE session_plan_steps ADD COLUMN IF NOT EXISTS replaced_reason TEXT;
|
||||
@@ -47,6 +47,24 @@ Read-only commands auto-run (no approval). Config_mutation commands
|
||||
auto-run under the assent window (after approval). Destructive commands
|
||||
always need explicit typed confirmation.
|
||||
|
||||
**Never mark a step `done` if its tool calls errored.** If `run` timed out,
|
||||
`update_entity_attributes` returned "not found", `create_relationship` returned
|
||||
"source entity not found", or any tool returned an error — the step is NOT done.
|
||||
Diagnose the error, try an alternative (e.g. use `create_entity` when
|
||||
`update_entity_attributes` reports the entity doesn't exist), and only advance
|
||||
to `done` when the step's intended work actually completed. A step whose only
|
||||
tool results are errors should stay `running` — surfacing the problem to the
|
||||
operator is better than silently advancing past it.
|
||||
|
||||
**Complete or skip steps — don't replace silently.** Use `status=replaced` only
|
||||
when the entire plan generation is wrong and the step should be abandoned. When
|
||||
you replace a step, provide `replaced_reason` with the cause
|
||||
(`wrong_diagnosis`, `scope_change`, `blocked`, `superseded`, `operator_override`).
|
||||
Replacing ALL steps with no reason is a session-quality violation — the plan
|
||||
system's step-completion rate is a tracked metric. Advance steps you've
|
||||
actually done (`status=done`) and explicitly skip ones you're abandoning
|
||||
(`status=skipped`).
|
||||
|
||||
### 6. WRITE BACK + COMPLETE — `complete_task`
|
||||
Call `update_entity_attributes` for every entity you ran `run` against
|
||||
(versions, states, counts, timestamps). Call `create_relationship` for any
|
||||
@@ -59,6 +77,15 @@ is a pure-DB Q&A that called *no* `run` at all (only get_entity/list_lxcs/
|
||||
search_knowledge): answer directly, `complete_task` with a one-line summary,
|
||||
no writeback needed.
|
||||
|
||||
**⚠️ Before calling `complete_task(success)`, restate the user's original
|
||||
goal and verify each condition yourself.** "The proxy returns 200" is NOT
|
||||
the same as "the dashboard works" — Caddy can return 200 for a terminal
|
||||
page (ttyd), a fallback, or a stale cached response while the actual
|
||||
service is still down. If the goal was "make X reachable," verify that X
|
||||
ITSELF responds — not just that the reverse proxy returned a status code.
|
||||
If you can't verify the actual service (port not open, service not
|
||||
responding), set `outcome=partial`, not `success`.
|
||||
|
||||
`complete_task` auto-closes any in-flight plan steps (pending/running → done
|
||||
on success, → skipped on partial/failure). You do NOT need to call
|
||||
`update_plan_step` for every step right before completing — once your work
|
||||
@@ -85,6 +112,13 @@ generation — the panel will show it as a new list), execute, write back,
|
||||
the panel in your reply. Never re-run `run` just to fix a display mismatch.
|
||||
- Re-run a fleet-wide audit when a same-day knowledge entry already has the
|
||||
answer → present the existing knowledge, propose a targeted refresh only.
|
||||
- Pivot to a subsystem unrelated to the user's expressed goal without asking →
|
||||
when investigation leads to a different subsystem or root cause (e.g.
|
||||
debugging DHCP reservations when the goal was "make the dashboard reachable"),
|
||||
call `session_questions` with the discovery and options BEFORE taking action.
|
||||
Example: "The dashboard hasn't started since July 19 — this predates my work.
|
||||
Do you want me to debug the dashboard service [A], skip it and stabilize the
|
||||
current state [B], or stop here [C]?"
|
||||
|
||||
## Source of truth
|
||||
|
||||
@@ -205,6 +239,21 @@ disappear.
|
||||
`list_lxcs` answers the same question in one call. Use it.
|
||||
- When a bulk tool's summary isn't enough for a specific entity, call the
|
||||
per-entity tool for that one entity — not for every entity in the fleet.
|
||||
- **Cap pre-plan exploration:** prefer `list_entities(limit)` +
|
||||
`get_entity_knowledge` (context for one entity, one call) over N+1
|
||||
`get_entity`/`get_relations` chains. If you've already called
|
||||
`get_entity_knowledge(slug)` and need more, call `get_entity(slug)` +
|
||||
`get_relations(slug)` — not `list_entities` without a limit scanning the
|
||||
whole entity table.
|
||||
- **Group parallel reads:** `get_entity_knowledge`, `search_knowledge`,
|
||||
`get_entity`, and `get_relations` are all read-only DB calls that can
|
||||
be batched in a single tool-call block. Do not sequentialize them one
|
||||
per turn when they are independent.
|
||||
- **Source-reading on prod (`run cat/grep/find /opt/…`) is NOT the way to
|
||||
learn how the platform works.** The MCP tools ARE the interface. If you
|
||||
need to understand a check lifecycle or a scheduler behavior, search
|
||||
`search_knowledge("oikos check lifecycle")` or ask the operator — do
|
||||
not treat the prod host as a code repository you grep.
|
||||
|
||||
## Policy awareness
|
||||
|
||||
@@ -355,6 +404,26 @@ port is busy, find a free one. Only surface to the operator if you've tried
|
||||
reasonable alternatives and none worked. An error in one step is not a reason
|
||||
to stop the entire turn — it's a reason to try a different approach.
|
||||
|
||||
**When you hit a genuine missing capability — STOP and ask, don't bypass:**
|
||||
If a tool returns `entity … not found` when you're trying to create something
|
||||
(a check, an ingress, a cert, a new service), the entity doesn't exist yet —
|
||||
use `create_entity`. If you need to retire/delete an entity, use
|
||||
`set_entity_state`. If you need to remove a relationship, use
|
||||
`end_relationship`. If NONE of these fit and you truly lack a tool, **tell the
|
||||
operator directly: "I need to X, but no MCP tool does that — can you create it
|
||||
via the API?"** Do NOT pivot to `run find/grep/cat` on `/opt/homelab-context`
|
||||
to reverse-engineer how the platform works — MCP tools are the interface, not
|
||||
the prod source tree.
|
||||
|
||||
**Self-grounding — use the DB, don't invent:**
|
||||
- `run` targets must be `host:<slug>`, `lxc:<slug>`, or `vm:<slug>` — never
|
||||
`ws:`, raw container names, or Docker Compose service aliases.
|
||||
- Never invent an IP address or subnet. Query `get_entity("service:oikos")` for
|
||||
the real API address, `get_entity("host:<name>")` for a host's real LAN IP,
|
||||
`list_lxcs` for container addresses. The DB is authoritative; your guess is
|
||||
wrong (the homelab has multiple subnets — `192.168.8.0/24`, `192.168.178.0/24`,
|
||||
etc. — and guessing the wrong one wastes turns).
|
||||
|
||||
**A hung command is not a failed command — investigate before retrying.**
|
||||
If a `run` call times out or returns "ERROR" (e.g. SSH killed, signal,
|
||||
gateway timeout), DO NOT immediately retry the same command with different
|
||||
@@ -391,6 +460,21 @@ before producing the plan. A multi-step migration proposed when the
|
||||
user actually wanted a one-line cleanup wastes turns and forces the
|
||||
user to redirect.
|
||||
|
||||
**Scope gate — ask before chasing unrelated subsystems.** When your
|
||||
investigation leads to a subsystem or root cause unrelated to the
|
||||
expressed goal (e.g. the user asked "why is X unreachable?" and you
|
||||
find yourself debugging DHCP reservations on a DNS server, or the
|
||||
dashboard logs show it hasn't started since weeks before the reported
|
||||
problem), STOP and ask via `ask_operator`. Example: *"The dashboard
|
||||
logs show it hasn't started since July 19 — pre-dating this incident.
|
||||
Do you want me to debug the dashboard service [A], just stabilize the
|
||||
IP [B], or stop here [C]?"* Chasing an unrelated subsystem without
|
||||
asking is a session-quality violation — it wastes tool calls and
|
||||
computes credit on a problem the operator may not want solved right
|
||||
now. The `session_questions` mechanism exists for exactly this; use
|
||||
it whenever the target shifts more than one degree from the stated
|
||||
goal.
|
||||
|
||||
**Multi-goal sessions: summarize the arc, not just the last goal.**
|
||||
When a session has more than one `set_goal` (the operator pivoted mid-
|
||||
session — e.g. "actually, just keep ludo-library"), the final
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
# 2026-08-03 — Session review: `service:haos` monitoring + agent capability gaps
|
||||
|
||||
**Status:** Plan (audit complete; ready to implement).
|
||||
**Reviewed session:** `23da10db-46a9-444c-bbde-ca9457bd9087` — *"Work out what
|
||||
monitoring checks service:haos should have and configure them."*
|
||||
**Method:** Direct Postgres read of `agent_sessions`/`agent_messages`/
|
||||
`agent_activity`/`session_plan_steps` on the prod mac-mini (oikos prod runs here
|
||||
in docker compose project `oikos`; gateway `:8092`), cross-referenced with the
|
||||
code paths in `internal/mcp`, `internal/httpapi`, `internal/policy`,
|
||||
`internal/checkdefaults`, `internal/db/seed.go`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Session audit (objective vs outcome)
|
||||
|
||||
| Dimension | Finding |
|
||||
|---|---|
|
||||
| Objective | Determine + configure monitoring checks for `service:haos` (HAOS VM 108, `home.hubris.network`, `192.168.8.101:8123`). |
|
||||
| Outcome | ❌ **Failed/stuck.** `status=executing`, `outcome=null` ~4 min after last activity (UTC); never reached a terminal state. Only the *existing* `check:vm-status:vm:haos:0` stub got populated; the three **new** checks (`http:service`, `http:ingress`, `cert-expiry`) and their `ingress:`/`cert:` entities were never created. |
|
||||
| Tool calls | **116** (vs the >30 N+1 failure signature). ~45 redundant `list_entities`/`get_entity`/`get_relations`, then a ~15-min storm of `run` doing `find`/`grep`/`cat` on prod source. |
|
||||
| Plan | 8 steps proposed; steps 1–4 genuinely done; **step 5 falsely marked "done"** after both its tool calls errored `entity not found`; steps 6–8 never started. |
|
||||
| Operator friction | 3 manual interventions: `status`, `proceed`, *"why dony you use the mcp?"*; plus a **44-minute approval stall** (19:52→20:36) on two trivial reachability curls. |
|
||||
| Severity | **blocker** (capability gap) + **friction** (classifier, plan-state, reaping). |
|
||||
|
||||
### Timeline (UTC)
|
||||
- **19:45–19:50** — read-only exploration; `run` correctly blocked ("No plan… call set_goal then propose_plan"). Good guard.
|
||||
- **19:50** — `propose_plan` (8 steps).
|
||||
- **19:52** — two `curl … -o /dev/null -w '%{http_code}'` reachability probes → both classified `config_mutation` → one queued for approval (`019fc92e…`), second blocked ("approval already pending").
|
||||
- **19:52 → 20:36 (44 min)** — idle, waiting on operator approval.
|
||||
- **20:36** — approval granted ("auto via assent window"); both curls → 200/200.
|
||||
- **20:37** — step 4 ✅: populated `check:vm-status:vm:haos:0` + `checks` edge.
|
||||
- **20:37:58** — step 5 ❌: `update_entity_attributes("check:http:service:haos:0")` → **`entity not found`**; `create_relationship` → **`source entity not found`**. *(There is no create tool.)*
|
||||
- **20:38–20:47** — spiral: `search_knowledge` (empty), then `run find/grep/cat` across `/opt/homelab-context/**/*.go` to reverse-engineer check creation. Reads `checkdefaults.go`, `monitoring.go`, `default_checks.go`, `checks.go`, `coverage.go`.
|
||||
- **20:42** — sets `service:haos` `monitoring: ["http"]` via `update_entity_attributes`, hoping `checkdefaults.Ensure()` auto-generates. **It does not** (see A2).
|
||||
- **20:42–20:50** — tries to reach the REST API directly: `psql` on hubris (cmd 127), `docker exec` on hubris (docker absent), `curl http://192.168.178.25:8090` (wrong subnet; real net is `192.168.8.x`; exit 7), `curl http://oikos-api:8090` (MCP routes to hubris which can't resolve the mac-mini docker alias; 30s timeouts ×2), `ssh root@192.168.178.25` (no route). Final `update_entity_attributes` on `ingress:`/`cert:` → `not found`.
|
||||
- **20:50:38** — last activity: a failed 30s `run`. Session goes silent, never terminates.
|
||||
|
||||
---
|
||||
|
||||
## 2. Root-cause findings (with code evidence)
|
||||
|
||||
### A1 — No entity-creation capability in the MCP toolset *(the blocker)*
|
||||
`internal/mcp/tools.go` registers **37 tools**; the only entity-mutation surface is
|
||||
`update_entity_attributes` (merge into an **existing** entity) and
|
||||
`create_relationship` (needs **existing** source+target). Neither can create a new
|
||||
entity. The capability **does** exist at the HTTP layer — `CreateEntity`
|
||||
(`internal/httpapi/impl.go:865`, `POST /api/v1/entities`) — it is simply not exposed
|
||||
to the agent. Every "set up / onboard / configure entity X" task that needs a new
|
||||
check/ingress/cert/service hits this wall.
|
||||
|
||||
### A2 — MCP `update_entity_attributes` bypasses `ensureDefaultChecks`
|
||||
`ensureDefaultChecks` (`internal/httpapi/default_checks.go:20`) is invoked **only**
|
||||
from the HTTP handlers: `CreateEntity` (`impl.go:1012`) and `PatchEntity`
|
||||
(`impl.go:1280`). `grep ensureDefaultChecks internal/mcp/` → **no matches**: the MCP
|
||||
tool writes attributes straight to the store, so flipping `service:haos`
|
||||
`monitoring:["http"]` never regenerated its checks. The agent's fallback strategy
|
||||
was structurally doomed via MCP.
|
||||
|
||||
### A3 — `-o /dev/null` curl idiom misclassified as `config_mutation`
|
||||
`internal/policy/command.go:106` `curlMutateRe` matches `(?:^|\s)-(?:d|F|T|o)\b` — so
|
||||
`-o` (output-file) is treated as mutation. The canonical read-only reachability probe
|
||||
`curl -sS -o /dev/null -w '%{http_code}' …` therefore escalates to approval. This is
|
||||
the entire 44-minute stall. (`curlIsReadOnly` at `command.go:307` only passes for GET
|
||||
with no `-o`/`-d`/`-X`/`>`.) A pure GET that discards the body is the single most
|
||||
common health probe and shouldn't need approval.
|
||||
|
||||
### A4 — No platform self-knowledge doc for the check lifecycle
|
||||
`search_knowledge("create check entity how to add new check monitoring")` → empty.
|
||||
The agent re-derived the whole mechanism from source on prod (~15 min, dozens of
|
||||
`run`). There is no agent/operator runbook explaining: check slugs are
|
||||
`check:<kind>:<target>:<n>`; `check_defs` are derived from the type's `monitoring`
|
||||
spec by `checkdefaults.Ensure`; Ensure runs at **seed/deploy** and on **HTTP
|
||||
create/patch**, not via MCP.
|
||||
|
||||
### A5 — False plan progress (step marked done on failure)
|
||||
At 20:37:58 both tool calls for step 5 returned `error: entity not found`, yet the
|
||||
agent advanced step 5→`done`. Plan-state integrity hole: a step whose actions error
|
||||
should not transition to `done`. (`session_plan_steps` confirms seq 5 = `done`.)
|
||||
|
||||
### A6 — No "missing-capability" escalation; self-grounding failures
|
||||
On detecting the dead-end (no create tool) the agent never told the operator *"I lack
|
||||
a tool to create entities — please create them"*; instead it tried to bypass its own
|
||||
platform. Grounding errors: invented IP `192.168.178.25` (real LAN is `192.168.8.x`),
|
||||
ran `run` against `ws:mac-mini` ("unsupported target — must be host:/lxc:/vm:"),
|
||||
assumed `docker` exists on hubris, assumed the docker-alias `oikos-api` resolves from
|
||||
hubris. The agent didn't query `get_entity("service:oikos")` for the real address.
|
||||
|
||||
### A7 — Sessions never reap from `executing`
|
||||
Last activity 20:50; status still `executing` with no turn running. There is no
|
||||
idle-timeout / abandoned transition when a turn ends without resolution. (Fleet-wide:
|
||||
176 done / 9 failed / 1 executing; the 9 prior failures are pre-v0.15.0, mostly
|
||||
approval-stalls and entity-not-found — same families.)
|
||||
|
||||
### A8 — N+1 tool fan-out (116 calls)
|
||||
Dozens of redundant `list_entities`/`get_entity`/`get_relations` before proposing a
|
||||
plan, plus the source-reading `run` storm. Above the >30-per-turn signature; indicates
|
||||
weak bulk-tool use and under-constrained exploration before planning.
|
||||
|
||||
---
|
||||
|
||||
## 3. Improvement plan (ordered)
|
||||
|
||||
**Scope decision (confirmed with operator):** general `create_entity` MCP tool **+
|
||||
wire regen** — solves this case and the 67-entity blast radius (§4).
|
||||
|
||||
### Task 1 — `create_entity` MCP tool *(fixes A1; the centerpiece)*
|
||||
- Register a new tool `create_entity(slug, type, name, attributes?)` in
|
||||
`internal/mcp/tools.go` that **reuses** `httpapi.CreateEntity`
|
||||
(`impl.go:865`) / the same store path — do not hand-roll. It must run
|
||||
`ensureDefaultChecks` (free, since it goes through the create path).
|
||||
- **Approval policy:** no approval required for the entity itself — it mutates the
|
||||
knowledge graph, matching the existing no-approval stance of
|
||||
`update_entity_attributes`/`create_relationship`/`upsert_knowledge`. (Derived checks
|
||||
are safe/read-side; if a check kind is ever deemed mutating, gate *that* in the
|
||||
scheduler, not here.)
|
||||
- Validate `type` against `entity_types`; reject unknown slugs/types with a clear
|
||||
error. Idempotent on existing slug (return the existing entity, mirroring the HTTP
|
||||
`ETag`/conflict behavior).
|
||||
- Expose to the agent via the tool-list build path used by `cmd/nomos/agent.go`.
|
||||
|
||||
### Task 2 — MCP `update_entity_attributes` triggers `ensureDefaultChecks` *(fixes A2)*
|
||||
- After the attribute merge in the MCP handler, call `ensureDefaultChecks` with the
|
||||
post-merge entity (same args as `impl.go:1280`). This makes "set monitoring → checks
|
||||
regenerate" work via MCP, matching HTTP semantics.
|
||||
- Mind the `default_checks.go:14-19` caveat: a service whose address comes from its
|
||||
host edge may still produce no checks until the hosting edge exists — log/return
|
||||
that as an explicit result so the agent knows to create the edge next.
|
||||
|
||||
### Task 3 — Classifier: read-only `curl` with `-o /dev/null` *(fixes A3)*
|
||||
- In `internal/policy/command.go` `curlIsReadOnly`, treat `-o /dev/null` (and
|
||||
`--output /dev/null`) as read-only — it's a no-op sink. Keep `-o <realpath>` as
|
||||
mutation. Add `TestClassifyCommand_CurlDevNull_ReadOnly` next to the existing
|
||||
`TestClassifyCommand_CurlPipeSh_ConfigMutation`.
|
||||
- Coach complement: in `nomos/SOUL.md`, note that reachability probes should use
|
||||
`curl -I` or `-o /dev/null` GETs (now read-only) rather than POSTs.
|
||||
|
||||
### Task 4 — Plan-state integrity: don't mark `done` on errored actions *(fixes A5)*
|
||||
- In `cmd/nomos` (`agent.go`/`tasks.go` where `update_plan_step` is emitted), a step
|
||||
whose turn ended with only error/`not-found` tool results must **not** auto-advance
|
||||
to `done`; leave it `running`/`blocked` and surface the failure to the operator.
|
||||
Minimal: if every tool call in the step returned an `error:*` result, hold the step.
|
||||
|
||||
### Task 5 — Stuck-session reaping *(fixes A7)*
|
||||
- Add an idle sweep (extend the existing continuation/idle worker in `cmd/nomos`) that
|
||||
transitions a session from `executing`→`failed` (or a new `stuck`) when no turn has
|
||||
run for N minutes and no approval is pending. Emit an event so the UI (F3 terminal
|
||||
handling) clears the spinner. Pick N (recommend 30 min) — confirm in review.
|
||||
|
||||
### Task 6 — Missing-capability escalation + grounding *(fixes A6)*
|
||||
- `nomos/SOUL.md`: when a mutation tool returns `entity … not found` on a create
|
||||
intent, the agent must **stop and ask the operator** (or now use `create_entity`)
|
||||
rather than pivot to `run`/SSH/API-bypass. Forbidden: inventing IPs/subnets; instead
|
||||
`get_entity("service:oikos")` for the real API address. `run` targets must be
|
||||
`host:/lxc:/vm:` slugs (state the contract explicitly).
|
||||
|
||||
### Task 7 — Runbook: "how checks work / how to add monitoring" *(fixes A4)*
|
||||
- Upsert a knowledge doc (via `upsert_knowledge`, linked to the `agent:nomos` and
|
||||
`document:infrastructure/monitoring` entities) covering: check slug grammar,
|
||||
`checkdefaults.Ensure` triggers (seed + HTTP create/patch, now also MCP), the
|
||||
`monitoring` per-entity override, the host-edge caveat, and the canonical way to add
|
||||
monitoring to an entity (create/patch entity → checks derive).
|
||||
|
||||
### Task 8 — (Lower priority) exploration budget / bulk-tool use *(A8)*
|
||||
- `nomos/SOUL.md`: prefer `list_entities(limit)` + `get_entity_knowledge` bulk calls
|
||||
over N+1 `get_entity`/`get_relations` fans; cap pre-plan exploration. Optional
|
||||
guardrail in `agent.go` (warn at >N same-tool calls per turn).
|
||||
|
||||
### Recommended sequence
|
||||
1 → 2 → 3 → 4 → 7 → 5 → 6 → 8. (1+2 unblock the whole task class; 3 kills the
|
||||
approval stall; 4+5 fix state integrity; 7 is cheap leverage; 6+8 are persona
|
||||
hardening.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Uncovered cases — the capability-gap blast radius
|
||||
|
||||
The existing F1–F8 plans (`2026-08-03-nomos-chat-reliability-and-ux-audit.md`,
|
||||
shipped v0.15.0) and the turn-scheduler review cover **only** UI / streaming / turn
|
||||
serialization / connection UX. **None** addresses agent *capability* or
|
||||
MCP↔HTTP integration. This session exposes the uncovered class:
|
||||
|
||||
- **67 entities currently have no `check:` relationship** (DB query): 40 `lxc`, 26
|
||||
`service`, 1 `vm`. Any "add monitoring to X" task fails identically until Tasks 1+2.
|
||||
- **Whole task families blocked by the no-create gap:** onboarding a new host/LXC/VM,
|
||||
declaring a new service/ingress/cert/dns, adding any check that doesn't already
|
||||
exist, registering a relationship target that doesn't exist yet. All currently
|
||||
require an operator to hand-edit `seeds/inventory.yaml` and re-seed.
|
||||
- **MCP↔HTTP semantic drift (generalize A2):** audit other MCP mutation tools for
|
||||
side-effects that the HTTP handlers perform but the MCP path skips (check regen,
|
||||
drift-flagging, audit fields, idempotency). Each is a latent "agent did the right
|
||||
thing but nothing happened" bug.
|
||||
- **Classifier read-only false-positives (generalize A3):** beyond `-o /dev/null`,
|
||||
review other common read-only idioms that escalate (`curl` with benign flags,
|
||||
compound read-only commands) — friction compounds into approval stalls and stuck
|
||||
sessions.
|
||||
- **No terminal/`stuck` reaping (generalize A7):** any turn that ends unresolved
|
||||
leaves the session `executing` forever; the UI never shows "done/failed".
|
||||
|
||||
---
|
||||
|
||||
## 5. Validation
|
||||
|
||||
- **Task 1/2:** `go test ./internal/mcp/... ./internal/httpapi/...` — new test creates
|
||||
`check:http:service:haos:0` via `create_entity`, asserts the entity exists **and**
|
||||
that a `check_def` row was derived; then `update_entity_attributes(service:haos,
|
||||
monitoring:["http"])` via MCP and assert checks regenerate (currently absent).
|
||||
- **Task 3:** `go test ./internal/policy/` — `curl -sS -o /dev/null -w '%{http_code}'
|
||||
URL` ⇒ `read_only`; `curl -o /tmp/x URL` ⇒ `config_mutation`.
|
||||
- **Task 4:** `cmd/nomos` test — a step whose only tool result is `error:*` stays
|
||||
non-`done`.
|
||||
- **Task 5:** idle-sweep test — session with no turn for N min and no pending approval
|
||||
⇒ `failed` (+ event emitted).
|
||||
- **End-to-end re-run:** replay the haos goal against a local nomos; expect the three
|
||||
checks + `ingress:`/`cert:` entities created in <15 tool calls with **zero**
|
||||
approvals and a `done` outcome.
|
||||
|
||||
## 6. Out of scope / open questions
|
||||
- Whether `create_entity` for sensitive types (e.g. `secret`, `key`) should require
|
||||
approval even though it's graph-only — recommend: same no-approval stance now, add
|
||||
type-specific gating later if abused.
|
||||
- The exact stuck-reap window N (recommend 30 min) and whether to introduce a distinct
|
||||
`stuck` status vs reuse `failed`.
|
||||
- Whether to also expose a `delete_entity`/`retire_entity` MCP tool (not needed for
|
||||
this case; lifecycle retirement is a separate flow).
|
||||
@@ -1,5 +1,9 @@
|
||||
# 2026-07-21 Chat window full polish
|
||||
|
||||
**Status:** Implemented. Streaming affordance, inline tool rendering, message
|
||||
timestamps, code-copy buttons, and per-session store isolation all landed in
|
||||
`web/src/lib/components/ChatThread.svelte` + the chat stores (v0.8.x–0.10.x).
|
||||
|
||||
## Context
|
||||
|
||||
After fixing the streaming reactivity bug and merging the double thinking
|
||||
@@ -1,6 +1,9 @@
|
||||
# Plan: Make health reflect reality + complete the knowledge graph
|
||||
|
||||
Status: ready for implementation · Created 2026-07-29
|
||||
Status: Implemented (v0.14.x–0.16.x). Shipped across `c9a00a9` (per-entity
|
||||
monitoring override), `a3914eb`/`8eb1ca2` (process check opt-in + probe_unit),
|
||||
`0929c17` (discover_infra_drift), and the vm-status/layered-probe/route-via-
|
||||
proxmox-host decisions now in project memory. Created 2026-07-29.
|
||||
|
||||
## Context
|
||||
|
||||
503
plans/done/2026-08-03-cyberspace-style-adoption.md
Normal file
503
plans/done/2026-08-03-cyberspace-style-adoption.md
Normal file
@@ -0,0 +1,503 @@
|
||||
# 2026-08-03 — Adopt cyberspace.online terminal aesthetic + dithered images
|
||||
|
||||
**Status:** Implemented in v0.16.0 (`757ef2f`). Shipped as a **full theme
|
||||
replacement** (Terracotta/Carbon → cyberspace BBS/terminal style), not the
|
||||
opt-in addition originally drafted below — the operator chose full replacement
|
||||
during execution (see decision `theme.replace_with_cyberspace`). The `<RasterImage>`
|
||||
Atkinson-dithering component and the warm-cream/JetBrains-Mono look landed as
|
||||
drafted; only the "opt-in vs replace" scope changed.
|
||||
|
||||
Adopt the look of https://cyberspace.online/ (a BBS / "social media
|
||||
de-imagined" terminal aesthetic) as a **new, opt-in theme family** in oikos,
|
||||
with **both light and dark variants**, plus a reusable **`<RasterImage>`**
|
||||
component that renders images to a `<canvas>` with Atkinson dithering (the
|
||||
"kinda dithered" image style). The existing Terracotta/Carbon themes stay the
|
||||
default; this adds, it does not replace.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
1. Add a third theme family — **"Cyberspace Dark"** and **"Cyberspace Light"** —
|
||||
wired through the same `--background` / `--foreground` / … token layer every
|
||||
component already uses, so nothing in the UI tree changes; only the tokens
|
||||
get new values. Square corners (`--radius: 0`), warm cream-on-black, mono
|
||||
everything.
|
||||
2. Extend `web/src/lib/stores/theme.svelte.ts` from a 2-state `'light'|'dark'`
|
||||
toggle to a named-theme model, keeping `.dark` class behavior for
|
||||
compatibility.
|
||||
3. Self-host JetBrains Mono (body) + a pixel/terminal face (VT323 or Departure
|
||||
Mono) for the logo/headings accents, replacing the Google Fonts `<link>`.
|
||||
4. Build `web/src/lib/components/RasterImage.svelte`: draws any image to a
|
||||
`<canvas>` reduced to a 2-color (theme `fg`/`bg`) palette via **Atkinson
|
||||
dithering**, with an `<img>` fallback and a skeleton placeholder — exactly
|
||||
the cyberspace pattern. Re-renders when the theme changes (palette flips).
|
||||
5. Optional cosmetic idioms (terminal-box focus ring, braille spinner, `<s>`
|
||||
strike lists) as small additive utilities, not a redesign.
|
||||
|
||||
The whole thing is **non-breaking and incremental**: each step ships behind the
|
||||
existing theme picker, so Terracotta/Carbon users see nothing until they opt in.
|
||||
|
||||
---
|
||||
|
||||
## 1. Extracted style spec (source of truth from cyberspace.online)
|
||||
|
||||
Captured from the live site's SSR HTML + inline boot script. This is the
|
||||
reference the tokens below are derived from.
|
||||
|
||||
### 1.1 Color model
|
||||
|
||||
Cyberspace defines **exactly three colors per theme** — `fg`, `bg`, `fgDim` —
|
||||
applied to CSS custom properties. Everything else (borders, primary, cards) is
|
||||
*derived* from those three. There are 11 named themes total; the two we care
|
||||
about:
|
||||
|
||||
| Theme | `fg` (text) | `bg` (canvas) | `fgDim` (muted) |
|
||||
|---------|--------------|---------------|-----------------|
|
||||
| Dark | `#efe5c0` | `#000000` | `#a89984` |
|
||||
| Light | `#000000` | `#efe5c0` | `#3a3a3a` |
|
||||
|
||||
Note the elegance: **light and dark are exact inverses** — they share the same
|
||||
warm cream (`#efe5c0`, a Gruvbox-ish paper tone) and just swap which side of it
|
||||
is ink vs. paper. The muted tone `#a89984` is straight out of the Gruvbox
|
||||
palette. This is why both themes read as "the same site" despite opposite
|
||||
polarity.
|
||||
|
||||
Boot-time fallback (the site's original/GRiD theme) is amber `#FF9810` on
|
||||
`#120900` — useful as a *third* optional accent if we ever want a true-phosphor
|
||||
variant.
|
||||
|
||||
### 1.2 Type
|
||||
|
||||
- **Body / mono:** JetBrains Mono (self-hosted `.woff2`, Regular).
|
||||
- **Boot + logo accents:** Departure Mono (self-hosted `.woff2`). A quirky
|
||||
monospace; VT323 (Google, free) is a close, easy substitute.
|
||||
- **Stylized wordmark** (`ᑕ¥βєяรקค¢є`, class `.font-vt`): a terminal/pixel face.
|
||||
Rule lives in their external `entry.*.css` (not in the SSR dump); VT323 is the
|
||||
safe assumption.
|
||||
|
||||
cyberspace sets `font-mono` on the root wrapper — the **entire UI is
|
||||
monospace**. There is no proportional body face. Headings use the same mono
|
||||
family at larger size / normal weight.
|
||||
|
||||
### 1.3 Layout & component idioms
|
||||
|
||||
- **Left rail nav:** fixed, icon-only when minimized (~80px), expands on click.
|
||||
Square buttons, Phosphor icons, uppercase `text-xs` labels.
|
||||
- **`.terminal-box`:** the universal card. Bordered (`border border-border`),
|
||||
**square corners** (`rounded-none` everywhere — `--radius` is effectively 0),
|
||||
and on focus/emphasis gets `ring-2 ring-fg` (a 2px ring in the foreground
|
||||
color).
|
||||
- **Emphasis by inversion:** active/primary state is `bg-fg text-bg` — fill with
|
||||
foreground ink, text becomes the canvas color. No separate "accent" hue; the
|
||||
accent *is* fg.
|
||||
- **Strikethrough as a feature list:** `<s>Ads</s> <s>Videos</s> …` — crossed-out
|
||||
`<s>` elements spell out what the product removes. Cheap, on-brand.
|
||||
- **Braille spinner:** `BrailleSpinner` component animates braille block chars
|
||||
(`⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`) for loading states instead of a circle.
|
||||
- **Max content width** `max-w-4xl`, centered; generous vertical rhythm; thin
|
||||
2px scrollbars colored `--color-border`.
|
||||
- Borders are `1px solid` in a border color derived from `fg`/`fgDim` at low
|
||||
alpha (their `--color-border` is not literally in the dump, but every
|
||||
bordered surface uses it, and it tracks `fg`).
|
||||
|
||||
### 1.4 The dithered image (`RasterImage`) — what we actually know
|
||||
|
||||
From the SSR HTML the component is unambiguous about its *shape*, silent on its
|
||||
*algorithm* (the dither JS is in an external `/_nuxt/*.js` bundle not present in
|
||||
the page dump):
|
||||
|
||||
- Renders a **`<canvas>`** as primary output, with an **`<img>` fallback** as a
|
||||
sibling. Parent selectors `[&>canvas]:max-w-full [&>canvas]:h-auto` and
|
||||
`[&>img]:…` size both responsively.
|
||||
- Emits a **`.raster-image-skeleton`** placeholder (empty div, `background:
|
||||
var(--color-bg)`) during SSR/before hydration — no flash of the raw photo.
|
||||
- Scoped styles (`data-v-4d61df89`): `.raster-image { display:block }`,
|
||||
`.raster-image-skeleton { display:block; background:var(--color-bg) }`.
|
||||
|
||||
**Inferred technique** (standard for this look): Canvas 2D → `drawImage` →
|
||||
`getImageData` → per-pixel luminance reduction to a 2-color palette (`fg`/`bg`)
|
||||
with an **error-diffusion** pass (Atkinson or Floyd–Steinberg) → `putImageData`.
|
||||
This produces the characteristic speckled 1-bit halftone. Target is almost
|
||||
certainly the theme's own fg/bg, which is *why* the dithered art recolors
|
||||
correctly when you flip themes.
|
||||
|
||||
We will implement Atkinson (see §4) — it's the classic Mac/BBS dither, slightly
|
||||
softer than Floyd–Steinberg, and matches "kinda dithered" precisely.
|
||||
|
||||
---
|
||||
|
||||
## 2. Recommended approach: opt-in theme family (not a rebrand)
|
||||
|
||||
oikos today = Art-Nouveau / terracotta / rounded / serif-heading (Inknut
|
||||
Antiqua), floating-window desktop shell. cyberspace = BBS / mono / square /
|
||||
cream-on-black. These are **opposite poles**; a flat rebrand would discard the
|
||||
existing art direction and rework every component's rounding/spacing.
|
||||
|
||||
**Decision: add cyberspace as a new theme family, selectable in the existing
|
||||
theme picker.** This is low-risk, reversible, and lets the dithered images +
|
||||
terminal idioms land incrementally. The full-rebrand alternative is documented
|
||||
in §7 for if you later decide to make it the default.
|
||||
|
||||
Because every oikos component consumes colors through the Tailwind v4 token
|
||||
layer (`--background`, `--foreground`, `--card`, `--border`, `--primary`, …)
|
||||
defined in `web/src/app.css` `@theme inline`, a new theme is **just a new set
|
||||
of values for those same custom properties** — zero component edits required
|
||||
for the recolor. That indirection is the whole reason this is cheap.
|
||||
|
||||
---
|
||||
|
||||
## 3. Theme token additions (`web/src/app.css`)
|
||||
|
||||
Add two new blocks alongside the existing `:root` (Terracotta) and `.dark`
|
||||
(Carbon). They set the *same* token names to cyberspace's values, plus pin
|
||||
`--radius: 0` for square corners and remap fonts (see §5).
|
||||
|
||||
Driven by a `data-theme` attribute on `<html>` (set by the store, §6), so all
|
||||
four states — Terracotta, Carbon, Cyberspace Dark, Cyberspace Light — coexist:
|
||||
|
||||
```css
|
||||
/* ── Cyberspace Dark (cream on black) ── */
|
||||
:root[data-theme='cyber-dark'] {
|
||||
--radius: 0px;
|
||||
--background: #000000;
|
||||
--foreground: #efe5c0;
|
||||
--card: #000000; /* cyberspace has no card tint; cards are just bordered bg */
|
||||
--card-foreground: #efe5c0;
|
||||
--popover: #000000;
|
||||
--popover-foreground: #efe5c0;
|
||||
--primary: #efe5c0; /* emphasis = fg ink */
|
||||
--primary-foreground: #000000; /* inverted */
|
||||
--secondary: #1a1a1a;
|
||||
--secondary-foreground: #efe5c0;
|
||||
--muted: #141414;
|
||||
--muted-foreground: #a89984; /* fgDim */
|
||||
--accent: #efe5c0;
|
||||
--accent-foreground: #000000;
|
||||
--destructive: #cc241d; /* Gruvbox red, sits in the same palette */
|
||||
--destructive-foreground: #efe5c0;
|
||||
--border: color-mix(in oklab, #efe5c0 22%, transparent); /* fg-derived hairline */
|
||||
--input: color-mix(in oklab, #efe5c0 28%, transparent);
|
||||
--ring: #efe5c0; /* the ring-2 ring-fg look */
|
||||
--sidebar: #000000;
|
||||
--sidebar-foreground: #efe5c0;
|
||||
--sidebar-primary: #efe5c0;
|
||||
--sidebar-primary-foreground: #000000;
|
||||
--sidebar-accent: #1a1a1a;
|
||||
--sidebar-accent-foreground: #efe5c0;
|
||||
--sidebar-border: color-mix(in oklab, #efe5c0 22%, transparent);
|
||||
--sidebar-ring: #efe5c0;
|
||||
--chart-1: #efe5c0; --chart-2: #a89984; --chart-3: #fabd2f;
|
||||
--chart-4: #b8bb26; --chart-5: #83a598; /* Gruvbox for charts */
|
||||
--success: #b8bb26; --warning: #fabd2f;
|
||||
|
||||
/* oikos semantic aliases (app.css :root block) */
|
||||
--bg: var(--background); --bg-surface: var(--card); --bg-deeper: #050505;
|
||||
--bg-hover: var(--secondary); --bg-active: var(--accent);
|
||||
--text: var(--foreground); --text-muted: var(--muted-foreground);
|
||||
--accent-blue: #83a598; --accent-green: var(--success);
|
||||
--accent-red: var(--destructive); --accent-orange: var(--warning);
|
||||
|
||||
/* terminal face for this theme only (see §5) */
|
||||
--font-sans: 'JetBrains Mono', ui-monospace, Menlo, monospace;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
|
||||
--font-heading: 'VT323', 'JetBrains Mono', monospace; /* pixel wordmark feel */
|
||||
}
|
||||
|
||||
/* ── Cyberspace Light (black on cream paper) — exact inverse ── */
|
||||
:root[data-theme='cyber-light'] {
|
||||
--radius: 0px;
|
||||
--background: #efe5c0;
|
||||
--foreground: #000000;
|
||||
--card: #efe5c0;
|
||||
--card-foreground: #000000;
|
||||
--popover: #efe5c0;
|
||||
--popover-foreground: #000000;
|
||||
--primary: #000000;
|
||||
--primary-foreground: #efe5c0;
|
||||
--secondary: #e0d6b0;
|
||||
--secondary-foreground: #000000;
|
||||
--muted: #e6dcc0;
|
||||
--muted-foreground: #3a3a3a; /* fgDim */
|
||||
--accent: #000000;
|
||||
--accent-foreground: #efe5c0;
|
||||
--destructive: #9d0006;
|
||||
--destructive-foreground: #efe5c0;
|
||||
--border: color-mix(in oklab, #000000 22%, transparent);
|
||||
--input: color-mix(in oklab, #000000 28%, transparent);
|
||||
--ring: #000000;
|
||||
--sidebar: #efe5c0;
|
||||
--sidebar-foreground: #000000;
|
||||
--sidebar-primary: #000000;
|
||||
--sidebar-primary-foreground: #efe5c0;
|
||||
--sidebar-accent: #e0d6b0;
|
||||
--sidebar-accent-foreground: #000000;
|
||||
--sidebar-border: color-mix(in oklab, #000000 22%, transparent);
|
||||
--sidebar-ring: #000000;
|
||||
--chart-1: #000000; --chart-2: #3a3a3a; --chart-3: #b57614;
|
||||
--chart-4: #79740e; --chart-5: #076678;
|
||||
--success: #79740e; --warning: #b57614;
|
||||
|
||||
--bg: var(--background); --bg-surface: var(--card); --bg-deeper: #e6dcc0;
|
||||
--bg-hover: var(--secondary); --bg-active: var(--accent);
|
||||
--text: var(--foreground); --text-muted: var(--muted-foreground);
|
||||
--accent-blue: #076678; --accent-green: var(--success);
|
||||
--accent-red: var(--destructive); --accent-orange: var(--warning);
|
||||
|
||||
--font-sans: 'JetBrains Mono', ui-monospace, Menlo, monospace;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
|
||||
--font-heading: 'VT323', 'JetBrains Mono', monospace;
|
||||
}
|
||||
```
|
||||
|
||||
Two notes:
|
||||
|
||||
- **`.dark` vs `data-theme`.** The current store flips `.dark` on `<html>`. To
|
||||
keep Carbon working unchanged, leave `.dark` logic alone and layer
|
||||
`data-theme` on top: when a cyberspace theme is active the store sets
|
||||
`data-theme` and **removes** `.dark` (cyberspace themes are self-contained —
|
||||
they set both polarities explicitly). See §6.
|
||||
- **Borders from `fg`.** cyberspace's hairline tracks the foreground, not a
|
||||
fixed gray. `color-mix(in oklab, <fg> 22%, transparent)` reproduces that and
|
||||
auto-flips between the two themes. Tune the % after visual review.
|
||||
|
||||
---
|
||||
|
||||
## 4. The dithered image component (`RasterImage.svelte`)
|
||||
|
||||
**File:** `web/src/lib/components/RasterImage.svelte` (sibling of the existing
|
||||
`Spinner.svelte`).
|
||||
|
||||
### 4.1 API
|
||||
|
||||
```svelte
|
||||
<RasterImage src={entity.iconUrl} alt="host icon" width={320} />
|
||||
<!-- optional: scale (downsample factor), threshold bias, mono palette override -->
|
||||
```
|
||||
|
||||
- `src`, `alt` — as `<img>`.
|
||||
- `width` — render width in CSS px; canvas is sized to this × natural aspect.
|
||||
Downscaling before dithering is what sells the "lo-fi" look (defaults ~256–
|
||||
320). Expose `scale` (0–1) to control.
|
||||
- Reads the active theme's `--foreground` / `--background` via
|
||||
`getComputedStyle(document.documentElement)` so the dither palette **follows
|
||||
the theme** (cream/black in cyber-dark, black/cream in cyber-light, and
|
||||
perfectly sensible in Terracotta/Carbon too).
|
||||
|
||||
### 4.2 Behavior
|
||||
|
||||
1. Show `.raster-image-skeleton` (empty, `background: var(--background)`) until
|
||||
the source image loads — matches cyberspace's no-flash placeholder.
|
||||
2. On load: create an offscreen canvas at `width × (h/w*width)`, `drawImage`
|
||||
(with `imageSmoothingEnabled = true` for the downscale), pull
|
||||
`getImageData`.
|
||||
3. Run **Atkinson dithering** to 2 colors:
|
||||
- For each pixel: luminance `Y = 0.299R + 0.587G + 0.114B`.
|
||||
- Threshold at 128 (+ optional `bias`), snap to either `fg` or `bg`.
|
||||
- Push **1/8 of the quantization error** to each of 6 neighbors (Atkinson's
|
||||
kernel): right, below-left, below, below-right, and two pixels down on the
|
||||
next-next row. (Atkinson diffuses less than Floyd–Steinberg → softer, more
|
||||
"screen-printed" — exactly the cyberspace feel.)
|
||||
- Write `fg`/`bg` (read from CSS vars at render time) into the buffer.
|
||||
4. `putImageData`. Canvas is the visible output; the loaded `<img>` is kept as
|
||||
`aria-hidden` fallback for no-JS / copy-image / accessibility.
|
||||
5. **Re-dither on theme change**: subscribe to the theme store; when it flips,
|
||||
re-read `--foreground`/`--background` and re-run steps 3–4 (cheap — the
|
||||
decoded `ImageBitmap` is cached, only the palette pass reruns). This is the
|
||||
detail that makes the art flip polarity with the theme toggle.
|
||||
6. **Respect `prefers-reduced-data` / reduced motion?** Dithering is not motion,
|
||||
but offer a `plain` prop to skip the canvas and render the raw `<img>` for
|
||||
users who want crisp photos (e.g. entity detail screens where legibility
|
||||
beats aesthetic).
|
||||
|
||||
### 4.3 Reference dither kernel (Atkinson)
|
||||
|
||||
```
|
||||
* → 1/8 1/8
|
||||
1/8 1/8 1/8 (current pixel = *)
|
||||
1/8 1/8 (* is at top-left of this 4×? — see standard Atkinson spread)
|
||||
```
|
||||
|
||||
Spread pattern (error e from pixel at (x,y) distributed):
|
||||
|
||||
```
|
||||
px x+1 (1/8) x+2 (1/8)
|
||||
x-1 (1/8) x (1/8) x+1 (1/8)
|
||||
x+1 (1/8) x+2 (1/8) [next row offsets]
|
||||
```
|
||||
|
||||
Concretely, 6 neighbors each get `e/8`: `(x+1,y)`, `(x+2,y)`, `(x-1,y+1)`,
|
||||
`(x,y+1)`, `(x+1,y+1)`, `(x,y+2)`. (Clamp at edges — drop, don't wrap.)
|
||||
|
||||
### 4.4 Where to use it
|
||||
|
||||
- Entity icons / host thumbnails in the KB and entity desktop (the obvious win).
|
||||
- Mascot or login/Config background art (`ConfigBackground.svelte` already
|
||||
exists — a dithered backdrop there would be striking).
|
||||
- Any user-uploaded image in chat/knowledge where we want the "de-imagined"
|
||||
tone. Keep it **opt-in per call site** via the `plain` prop — don't dither
|
||||
diagrams/screenshots that need to stay readable.
|
||||
|
||||
### 4.5 Cross-origin caveat
|
||||
|
||||
`getImageData` throws on tainted canvases. If `src` is cross-origin and the
|
||||
server doesn't send CORS headers, fall back to the plain `<img>` (log once).
|
||||
For self-hosted assets (the common case here) it's a non-issue.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fonts: self-host JetBrains Mono + VT323
|
||||
|
||||
cyberspace self-hosts both faces as `.woff2`. oikos currently pulls DM Sans /
|
||||
DM Mono / Inknut Antiqua from Google Fonts via a `<link>` in
|
||||
`web/index.html:10`.
|
||||
|
||||
- Drop `JetBrainsMono-Regular.woff2` and `VT323-Regular.woff2` under
|
||||
`web/static/fonts/` (or `web/public/fonts/` — match where static assets are
|
||||
served from; check `vite.config`).
|
||||
- Add `@font-face` blocks at the top of `app.css` with `font-display: swap`.
|
||||
- For the cyberspace themes only, the `--font-sans`/`--font-mono`/`--font-heading`
|
||||
overrides in §3 remap the families — Terracotta/Carbon keep DM Sans/Inknut
|
||||
untouched. This is the key trick: **font choice is part of the theme**, not a
|
||||
global swap, so the two art directions don't fight.
|
||||
- Leave the Google Fonts `<link>` in place for now (Terracotta/Carbon still need
|
||||
it); add a follow-up to self-host those too if we want to kill the external
|
||||
request entirely. Out of scope for this plan.
|
||||
|
||||
VT323 vs Departure Mono: VT323 is free on Google Fonts and trivial to self-host;
|
||||
Departure Mono is the authentic cyberspace face but needs a license check.
|
||||
**Recommend VT323** to start; swap to Departure Mono later if you want exact
|
||||
fidelity.
|
||||
|
||||
---
|
||||
|
||||
## 6. Theme store changes (`web/src/lib/stores/theme.svelte.ts`)
|
||||
|
||||
Current: `Theme = 'light' | 'dark'`, flips `.dark` class. Extend to a named set
|
||||
while preserving the existing API (callers of `toggleTheme`/`getTheme` keep
|
||||
working):
|
||||
|
||||
```ts
|
||||
export type ThemeName = 'terracotta' | 'carbon' | 'cyber-dark' | 'cyber-light'
|
||||
// Back-compat aliases used by existing callers:
|
||||
// 'light' -> 'terracotta', 'dark' -> 'carbon'
|
||||
```
|
||||
|
||||
- Store key stays `oikos-theme`; migrate old `'light'`/`'dark'` values on read.
|
||||
- `applyClass` becomes `applyTheme`: sets `data-theme` on `<html>` and toggles
|
||||
`.dark` **only** for `carbon` (so Terracotta and both cyberspace themes run
|
||||
with no `.dark`). This is important: the `.dark` block in `app.css` must not
|
||||
layer on top of the cyberspace token blocks — cyberspace sets its own
|
||||
polarities.
|
||||
- Update `THEME_LABELS` to the four names; update whatever UI surfaces the
|
||||
picker (search for `THEME_LABELS` / `toggleTheme` usages — likely
|
||||
`Settings.svelte` or the desktop shell's chrome) to a 4-option control instead
|
||||
of a binary toggle.
|
||||
|
||||
**Watch out:** any code that assumes `document.documentElement.classList.contains('dark')`
|
||||
≡ "dark colors" will be wrong for `cyber-dark`. Audit `grep -rn "classList.*dark\|\.dark" web/src` and prefer reading `getTheme()`/`data-theme` instead.
|
||||
|
||||
---
|
||||
|
||||
## 7. Optional cosmetic idioms (additive utilities)
|
||||
|
||||
Small, theme-aware utilities in `app.css` — usable in any theme but idiomatic
|
||||
for cyberspace:
|
||||
|
||||
- `.terminal-box` — `{ border:1px solid var(--border); border-radius:0 }` plus a
|
||||
`.terminal-box:focus-within { box-shadow: 0 0 0 2px var(--ring) }` to mirror
|
||||
the `ring-2 ring-fg` focus. Lets cards opt into the terminal look without a
|
||||
component rewrite.
|
||||
- `.braille-spinner` — keyframe cycling `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` as `::after` content, colored
|
||||
`var(--muted-foreground)`. Alternative to `Spinner.svelte` for loading states
|
||||
under cyberspace themes.
|
||||
- `.font-vt` — `{ font-family: var(--font-heading) }` so the wordmark class
|
||||
cyberspace uses maps to our heading var (VT323 under cyber themes, Inknut
|
||||
under Terracotta). Drop-in for any stylized title.
|
||||
- `.strike-list` — `li > s { color: var(--muted-foreground) }` convenience for
|
||||
the crossed-out feature-list pattern in marketing/empty states.
|
||||
|
||||
None of these are required for the theme to work; they're palette for the
|
||||
"de-imagined" voice where we want it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation order (incremental, each step shippable)
|
||||
|
||||
1. **Fonts** (§5) — self-host JetBrains Mono + VT323, `@font-face` in app.css.
|
||||
No visual change yet (only cyberspace themes reference them).
|
||||
2. **Tokens** (§3) — add the two `:root[data-theme='cyber-*']` blocks.
|
||||
3. **Store** (§6) — extend `theme.svelte.ts` to named themes + `data-theme`;
|
||||
update the picker UI. **At this point both cyberspace themes are live and
|
||||
fully recolor the whole app** — the cheapest milestone, biggest visible win.
|
||||
4. **`RasterImage.svelte`** (§4) — build + wire into entity icons and
|
||||
`ConfigBackground`. This is the "dithered image" deliverable.
|
||||
5. **Idioms** (§7) — terminal-box, braille spinner, etc., applied opportunistically.
|
||||
|
||||
Each step is independently mergeable. Step 3 alone satisfies "light + dark
|
||||
cyberspace themes"; step 4 satisfies "dithered images."
|
||||
|
||||
---
|
||||
|
||||
## 9. Verification
|
||||
|
||||
- `cd web && npm run build` (or the repo's build command — confirm in
|
||||
`web/package.json`) — Tailwind v4 must accept the new `data-theme` selectors
|
||||
and `color-mix()` (both standard; no config change expected).
|
||||
- `npm run check` / `svelte-check` for the store + component TS.
|
||||
- Manual: cycle all four themes in the picker; confirm no `.dark` bleed on
|
||||
`cyber-light`; confirm `RasterImage` re-dithers on theme flip; confirm
|
||||
`prefers-reduced-data`/`plain` prop shows crisp image; confirm cross-origin
|
||||
`src` degrades to `<img>` without console errors.
|
||||
- Lighthouse / a11y: 1-bit dithered images still need a real `alt` (kept on the
|
||||
fallback `<img>`); contrast on `#a89984`-on-black passes WCAG AA for body text
|
||||
(ratio ≈ 7.4:1) — fine.
|
||||
|
||||
---
|
||||
|
||||
## 10. Alternatives considered
|
||||
|
||||
- **Full rebrand (replace Terracotta/Carbon).** Highest visual payoff, highest
|
||||
cost: every component's rounding/serif/spacing was authored for the Art
|
||||
ouveau
|
||||
direction; square + mono would need a component-level sweep, not just tokens.
|
||||
Defer unless you want cyberspace as *the* oikos look — then do it as a
|
||||
follow-up that deletes Terracotta/Carbon and makes `cyber-dark` the sole
|
||||
default.
|
||||
- **CSS-only image dither (filters / SVG turbulence).** Cheaper, but can't do
|
||||
true 1-bit error diffusion or recolor to theme fg/bg. Rejected — the canvas
|
||||
pass is the whole point and is ~60 lines.
|
||||
- **Ordered (Bayer) dither instead of Atkinson.** More regular/grid-like
|
||||
("newspaper halftone"). Atkinson is softer and more terminal-like; keep
|
||||
Bayer as a `algorithm='bayer'` prop option later if wanted.
|
||||
- **Server-side dithering.** Could pre-dither icons at ingest. Rejected for
|
||||
now — client canvas keeps one source of truth (the original image) and lets
|
||||
the palette follow the live theme, which a baked asset can't.
|
||||
|
||||
---
|
||||
|
||||
## 11. Non-goals / out of scope
|
||||
|
||||
- Replicating cyberspace's sidebar-rail *layout* (oikos uses a floating-window
|
||||
desktop shell; the rail is a different app model). We take the *visual*
|
||||
language, not the IA.
|
||||
- Porting the 9 other novelty themes (C64, Matrix, VT320, …). Two (light/dark)
|
||||
satisfy the request; the token model makes adding more trivial later.
|
||||
- Removing the Google Fonts dependency for Terracotta/Carbon (follow-up).
|
||||
- Licensing/redistributing Departure Mono (use VT323 unless cleared).
|
||||
|
||||
---
|
||||
|
||||
## 12. Risks
|
||||
|
||||
- **`.dark` coupling.** Existing code may equate `.dark` with "dark UI".
|
||||
Mitigation: audit in step 3; the grep is small.
|
||||
- **Dither perf on large images.** Atkinson is O(n) and runs on a downscaled
|
||||
canvas (≤~320px wide), so per-image cost is negligible; but batch-rendering
|
||||
many entity icons on first paint could jank. Mitigation: dither lazily (on
|
||||
intersection) and cache the result on the element.
|
||||
- **Tainted canvas** on cross-origin images → silent fallback to `<img>`
|
||||
(already handled in the design).
|
||||
- **Token drift.** If a component hardcodes a color instead of using a token,
|
||||
it won't recolor under cyberspace. This is the same risk Carbon already has;
|
||||
no new exposure, just more visible under a stronger theme.
|
||||
184
plans/done/2026-08-03-nomos-chat-working-visibility.md
Normal file
184
plans/done/2026-08-03-nomos-chat-working-visibility.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 2026-08-03 — Nomos chat: working-visibility, message queue, generation-aware timeline
|
||||
|
||||
**Status:** Implemented (F1–F4) in v0.17.0. See
|
||||
[Resolution](#resolution-2026-08-03) at the end.
|
||||
|
||||
## Context (grounded in last-session logs + DB, not just code)
|
||||
|
||||
Operator report: *"On the chat window I can't tell the agent is working; it's
|
||||
making tool calls but no feedback. Typing returns 'Nomos is still finishing a
|
||||
previous step…'. Activity not up to date. Several plans at once, some don't
|
||||
execute."*
|
||||
|
||||
Verified against runtime state:
|
||||
|
||||
- **Last session `23da10db`** ran ONE live turn for **6m33s** (21 iterations,
|
||||
19:45:36→19:52:03, correlation `679566cb`). At 19:48:00 the operator typed
|
||||
`status`; at **19:48:05 the turn gate deferred it** (`turn already active,
|
||||
deferring operator message`). The operator could type at all only because the
|
||||
client had already lost the stream (`streaming=false`) while the server kept
|
||||
running — i.e. the client showed an *idle* window over a *working* turn. It
|
||||
ended `awaiting_input`.
|
||||
- **Turn runtimes are long**: sessions in the DB run 15-27 min
|
||||
(e.g. `44df8802` 24:24, `4319b9f8` 27:05). `handleChat` (main.go:170) has **no
|
||||
SSE keepalive**; inter-iteration gaps reach 20-40s, so a proxy/browser idle
|
||||
close mid-turn resets `streaming` while the turn continues on
|
||||
`context.Background()` (pctx).
|
||||
- **Re-proposing is real**: `44df8802` has **generation 1 (5 steps, all
|
||||
`replaced`) → generation 2 (25 steps, done)**, with **2 `propose_plan` + 52
|
||||
`update_plan_step`** calls persisted. The activity timeline renders every one
|
||||
of those across both generations.
|
||||
|
||||
## Root causes
|
||||
|
||||
- **G1 — "working" == `streaming`.** Every working-indication in the chat window
|
||||
(AgentTrace running status, indicator headline, stream cursor, panel spinner,
|
||||
`disabled={streaming}` input) is gated on the live SSE flag. A background turn
|
||||
(`resumeSession`/continuation worker) has no stream; a desynced long live turn
|
||||
has a dead stream. In both cases `streaming=false` while the server is actively
|
||||
working. The **session `status`** (`planning`/`executing`/`awaiting_input`) is
|
||||
the reliable "server is running a turn" signal and is already live-refreshed
|
||||
(`workspace.ts` `taskFor`, `STATUS_AFFECTING`), but the chat UI never uses it.
|
||||
- **G2 — busy-turn message is rejected, not queued.** main.go:292-302: the turn
|
||||
gate waits 5s then emits the "still finishing a previous step" error and
|
||||
returns. The user message *is* persisted (main.go:270) but is **inert** — the
|
||||
user must manually re-send.
|
||||
- **G3 — activity is poll/event laggy.** Tool-level activity derives from
|
||||
`messages`, refreshed only by the 3s poller; plan steps are **events-only**
|
||||
(`workspace.ts` `hydrateSession`) with no poll, so a missed `plan.proposed`
|
||||
event leaves the panel stuck on a stale generation.
|
||||
- **G4 — timeline is generation-unaware.** `activity.ts` `computeActivityLog`
|
||||
walks **all** messages' tool calls, so a re-proposed task renders N
|
||||
"Proposed plan" entries and attributes tools to steps via `currentStepSeq`
|
||||
inferred from `update_plan_step` calls across **every** generation — tools land
|
||||
under the wrong (current-gen) step or under steps that were `replaced`. This is
|
||||
the "several plans / some steps never run" view.
|
||||
|
||||
## Fixes (ordered)
|
||||
|
||||
### F1 — Status-driven `working` signal (fixes G1)
|
||||
Add a derived store `taskWorking(sessionId)` = `$streaming OR status ∈
|
||||
{planning, executing}` (explicitly **not** `awaiting_input` — that is paused for
|
||||
input), plus a global `currentWorking` for the main view backed by `currentTask`.
|
||||
Use it wherever `streaming` currently drives "is it working":
|
||||
- `ChatThread.svelte`: `traceStatus` last-message = `working ? 'running' : …`;
|
||||
`indicatorLabel` and the AgentTrace `status`/`label` props.
|
||||
- `TaskContextPanel.svelte:137` spinner and `UnifiedTimeline` `streaming` prop →
|
||||
`working`.
|
||||
- Keep a separate `streaming` for the literal "live text deltas are arriving"
|
||||
cursor; `working` is the superset for indicators/input.
|
||||
- Input stays **enabled** while `working` (the user must be able to interject);
|
||||
the send path queues when busy (F2). Show a muted "Nomos is working…" hint in
|
||||
the composer when `working && !streaming`.
|
||||
|
||||
### F2 — Queue operator messages; auto-run when free (fixes G2)
|
||||
- Server: in-memory per-session FIFO on the `agent` struct (mirrors `turnGate`),
|
||||
`{message, reply}` entries. `handleChat`: when the gate is busy, **enqueue**
|
||||
instead of rejecting, and emit a `queued` SSE event (replaces today's error at
|
||||
main.go:294-302). Persist the user message as today (already done pre-acquire).
|
||||
- Drain: arm a per-session drainer that, on gate release, acquires again and runs
|
||||
the next queued message as a normal turn (same persist/emit path as
|
||||
`handleChat`). Strictly one-at-a-time under the gate — this cannot stack turns
|
||||
(the hazard v0.15.0 F1 removed); background `resumeSession` keeps its
|
||||
non-blocking skip and never touches the queue.
|
||||
- If the session is terminal (`done`/`failed`) or `awaiting_input` when a queued
|
||||
message runs, `reopenSession`/answer handling applies as for any follow-up.
|
||||
- Frontend: on the `queued` event show an inline "Queued — will run when the
|
||||
current step finishes" chip on that user bubble; clear it when the turn's real
|
||||
events begin. Drop the humanized "still finishing" error for the busy case.
|
||||
|
||||
### F3 — SSE keepalive on `handleChat` (prevents the G1 desync at the source)
|
||||
Wrap `a.chat(...)` in a goroutine + `select` with a **10-15s ticker** that writes
|
||||
an SSE comment (`:keepalive\n\n`) and flushes, so 20-40s inter-iteration gaps no
|
||||
longer trip proxy/browser idle timeouts. Stop the ticker when `a.chat` returns.
|
||||
(EventSource ignores comment lines by spec — safe.)
|
||||
|
||||
### F4 — Generation-aware timeline + self-healing plan panel (fixes G3/G4)
|
||||
- `activity.ts` `computeActivityLog`: find the **last** `propose_plan` in the
|
||||
message stream; ignore `propose_plan`/`update_plan_step` calls **before** it
|
||||
for both rendering and `currentStepSeq` inference. Render at most one
|
||||
"Proposed plan" entry (the current generation). Steps continue to come from
|
||||
`$steps` (already current-gen via `fetchPlan` MAX(generation)). Optionally emit
|
||||
a single "Plan revised" entry when >1 generation exists.
|
||||
- Plan-panel resilience: on any `STATUS_AFFECTING` event (and on reconnect),
|
||||
re-fetch the plan (`fetchPlan`) in addition to the live `plan.proposed` handler,
|
||||
so a missed event self-heals instead of leaving a stale generation.
|
||||
|
||||
## Validation
|
||||
|
||||
- `go test ./cmd/nomos/`: extend `turngate_test.go`/new `messagequeue_test.go` —
|
||||
queued message runs strictly after release; FIFO order preserved across 3
|
||||
queued sends; a background `resumeSession` busy-skip does **not** consume or
|
||||
starve the queue; queued message runs even if session went `awaiting_input`.
|
||||
- Web `vitest`: `activity.test.ts` — add a 2-generation fixture (2× propose_plan,
|
||||
interleaved update_plan_step) asserting exactly one "Proposed plan" and correct
|
||||
step attribution to gen-2 steps; `chat`/store test — `working` is true from
|
||||
`status==='executing'` even with `streaming=false`; `queued` event renders the
|
||||
queued chip and clears on first tool_use.
|
||||
- Manual: (a) start a long task, **reload the window mid-turn** → the working
|
||||
indicator stays on (status-driven); (b) send a message mid-turn → "Queued" →
|
||||
runs after the turn; (c) open `44df8802`-style 2-gen session → timeline shows
|
||||
one plan, no ghost proposals.
|
||||
|
||||
## Risks
|
||||
|
||||
- **F2 must not reintroduce concurrent turns.** The queue drains one-at-a-time
|
||||
under the gate; background resume remains non-blocking and queue-agnostic.
|
||||
Existing `turngate_test.go` concurrency assertion (max in-flight = 1) must stay
|
||||
green.
|
||||
- **Status-driven `working` could stick on** if a terminal event is missed.
|
||||
Mitigated by the existing terminal `task.status` → `clearTurnState` recovery
|
||||
plus a `loadSessions` refresh on reconnect (F4).
|
||||
- **Keepalive comments** must stay SSE comments (`:` prefix) so they aren't
|
||||
parsed as events.
|
||||
|
||||
## Out of scope / follow-ups
|
||||
|
||||
- Model efficiency: the 8+ pure-exploration iterations (repeated
|
||||
`list_entities`/`get_relations`) that inflate turn length to 15-27 min —
|
||||
prompt/iteration-budget tuning, separate effort.
|
||||
- F8 from the prior plan (oldest-first timeline toggle; per-tool `tool.*` events
|
||||
for background turns). F1's status-driven `working` makes background work
|
||||
visible without live per-tool deltas, so this remains lower priority.
|
||||
|
||||
## Open implementation note
|
||||
|
||||
Host the per-session message queue on the `agent` struct (in-memory `map[string]
|
||||
[]queuedMsg` + per-session drainer goroutine), mirroring `turnGate`. No DB table
|
||||
needed — messages are already persisted by `handleChat` before enqueue; the queue
|
||||
only schedules *when* a turn runs, not *whether* the message is stored.
|
||||
|
||||
---
|
||||
|
||||
## Resolution (2026-08-03)
|
||||
|
||||
Implemented F1–F4 in v0.15.1 → v0.17.0 (the intermediate 0.16.0 was the
|
||||
cyberspace-aesthetic commit, landed via auto-pull during this work).
|
||||
|
||||
| Item | What shipped | Where |
|
||||
|---|---|---|
|
||||
| **F1** | Status-driven `working` signal (`taskWorking(sessionId)` / `currentWorking`) = live stream OR session status ∈ {planning, executing}. Drives the chat trace running state, the "thinking" headline, the activity spinner, and the timeline `streaming` prop — so a background/long/desynced turn still looks alive (the "can't tell it's working" symptom). The composer stays enabled during background work so the operator can interject. | `web/src/lib/stores/workspace.ts` (`isWorking`, `taskWorking`, `currentWorking`), `ChatThread.svelte` (`working` prop, `traceStatus`, indicator), `TaskContextPanel.svelte`, `SessionChatWindow.svelte`, `NewTaskChat.svelte`. |
|
||||
| **F2** | Operator messages sent during an in-flight turn are now QUEUED and auto-run when the gate frees, replacing the "still finishing a previous step… send it again" rejection. Per-session in-memory FIFO drained strictly one-at-a-time under the turn gate (no concurrent-turn reintroduction). A `queued` SSE event tells the client, which drops the optimistic bubble and shows a "Queued — will run when it finishes the current step" hint (derived from `working` + last-message shape, so it survives the poller). | `cmd/nomos/messagequeue.go` (+`messagequeue_test.go`), `agent.go` (queue field), `main.go` (`runChatTurn`, `drainQueued`, handleChat queue path), `continue.go` (resumeSession drains on release), `web/src/lib/types.ts` (`ChatQueuedEvent`), `chat.ts` (`queued` handling in sendSessionMessage/startTask). |
|
||||
| **F3** | SSE keepalive: a 12s `:keepalive` comment ticker during `handleChat` so 20-40s inter-iteration gaps no longer trip a proxy/browser idle timeout (the desync root cause). All SSE writes (events + keepalive) serialized through one mutex — `http.ResponseWriter` is not concurrency-safe. | `cmd/nomos/main.go` (`writeMu`/`writeEvent`, keepalive goroutine). |
|
||||
| **F4** | Generation-aware activity timeline: only the LAST `propose_plan` renders as "Proposed plan"; superseded ones collapse to a single "Earlier plan revised" marker, and step-attribution only follows the current generation's `update_plan_step` calls. Plus plan-panel self-heal: the plan is refetched (debounced) on any task-lifecycle event so a missed `plan.proposed` no longer freezes the panel on a stale generation. | `web/src/lib/stores/activity.ts` (`computeActivityLog`), `workspace.ts` (`schedulePlanRefetch`). |
|
||||
|
||||
**Verification:**
|
||||
- `go vet ./cmd/nomos/` clean; `go test ./cmd/nomos/` green, incl. new
|
||||
`messagequeue_test.go` (FIFO, requeueFront, per-session isolation, concurrency,
|
||||
drainQueued no-op-on-empty, drainQueued requeues-when-busy). Existing
|
||||
`turngate_test.go`/`continue_test.go` still green (single-flight guarantee
|
||||
intact).
|
||||
- Web `vitest` 72/72 green (added 2 F4 generation-awareness tests to
|
||||
`activity.test.ts`: one "Proposed plan" + revised marker + current-gen-only
|
||||
step attribution; plan-less Q&A attributes nothing).
|
||||
- `vite build` succeeds. `tsc --noEmit` shows only the pre-existing baseline
|
||||
errors (`ui/*`, `oidc.ts`, `windows.ts`, `workspace.ts:123/201/221`) noted in
|
||||
v0.15.0 — no new errors from this change. ESLint: no new errors (the one new
|
||||
`svelte/valid-compile` on `chatWorking` got the same disable its siblings have).
|
||||
|
||||
**Follow-ups (not in this pass):**
|
||||
- Model efficiency: the long (15-27 min) exploration-heavy turns that made the
|
||||
desync so painful — prompt / iteration-budget tuning, separate effort.
|
||||
- F8 from the prior plan (oldest-first timeline toggle; per-tool `tool.*` events
|
||||
for background turns). F1's status-driven `working` makes background work
|
||||
visible without live per-tool deltas, so this stays lower priority.
|
||||
191
plans/done/2026-08-04-chat-window-overhaul.md
Normal file
191
plans/done/2026-08-04-chat-window-overhaul.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 2026-08-04 — Chat interaction overhaul: inline progressive stream (Claude Code style)
|
||||
|
||||
**Status:** Planned — not started. (Refocused from the earlier feature-heavy
|
||||
draft; backend features deferred — see "Deferred".)
|
||||
|
||||
## Goal
|
||||
|
||||
Streamline agent interactions — thinking, plan, tool usage, responses — into
|
||||
**one linear progressive inline stream per turn** (the Claude Code / Cline /
|
||||
Roo pattern), instead of the current split where the transcript shows a
|
||||
collapsed trace and the real live activity lives in a separate rail timeline.
|
||||
The right rail becomes **graph-only** (and auto-zooms to fit all entities).
|
||||
|
||||
## Locked decisions (operator interview)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Live activity layout | **Inline stream (Claude Code)** — one progressive column per turn; rail keeps ONLY the Scope graph; Activity timeline tab removed |
|
||||
| Tool-call detail | **Per-tool progressive lines** — each tool its own compact live line (spinner → one-line result summary), expandable to raw |
|
||||
| Feature phases | **Defer** — edit/resubmit, @mentions, attachments are later phases; this plan is interaction-focused + graph auto-zoom |
|
||||
|
||||
## Diagnosis (grounded in current code)
|
||||
|
||||
- The transcript (`ChatThread` → `AgentTrace`) collapses a whole turn's tool
|
||||
calls into one line ("Proposed plan" / "N tool calls"), raw-JSON detail on
|
||||
expand. Not progressive; you can't see what's happening without expanding.
|
||||
- The actual live plan + tool timeline lives in the **right rail**
|
||||
(`TaskContextPanel` → `UnifiedTimeline`): newest-first backbone + tool stubs.
|
||||
So "what is the agent doing" is in a **second place** — a cognitive split.
|
||||
- `UnifiedTimeline` is imported **only** by `TaskContextPanel` (grep confirms),
|
||||
so removing the Activity pane is self-contained.
|
||||
- The `activityLog` **store** stays required: it feeds inline labels
|
||||
(`toolActivityLabel`), live `run` output (`toolsWithLive`), and the mascot
|
||||
(`mascot/stimuli.ts`). Only the timeline *view* is removed.
|
||||
- Tool events already arrive separately (`tool_use` then `tool_result` in
|
||||
`chat.ts`), and the activity log already carries humanized labels + per-tool
|
||||
`stepSeq` attribution. So progressive per-tool lines + step grouping are a
|
||||
**presentation** change, not a data/model change.
|
||||
- `run` results are free-form text (e.g. `"run on lxc:caddy: ERROR exit status
|
||||
1"`) → one-line result summaries are best-effort text parsing, no backend.
|
||||
|
||||
## Design
|
||||
|
||||
### D1 — One progressive inline stream per turn
|
||||
Replace `AgentTrace` (one collapsed blob per turn) with a new
|
||||
**`TurnTrace.svelte`** rendered inline for each assistant turn, top-to-bottom:
|
||||
1. **Live plan checklist** (only on the most-recent/running turn — see D3).
|
||||
2. **Tool lines grouped by plan step** (D2), then orphan tools (no step).
|
||||
3. **Streamed text answer** (existing `markdown-body prose-chat`), with the
|
||||
blinking cursor while streaming (existing).
|
||||
4. A compact **"Thinking" line** while `working` and before any output: reuses
|
||||
the existing `indicatorLabel` (running step → tool → "Agent is thinking…").
|
||||
Fades once text/tools arrive; reappears between steps.
|
||||
|
||||
### D2 — Per-tool progressive lines (the Claude-Code signature)
|
||||
One `ToolLine.svelte` per tool call (replaces `ToolCallCard`'s row style):
|
||||
- Left: state icon — spinner while `tool_use`-only, ✓ on result, ✗ on error.
|
||||
- Label: existing `toolActivityLabel(tool)` (humanized action).
|
||||
- **One-line result summary** on completion — new `toolResultSummary(tool)`
|
||||
in `activity.ts` (see plumbing). E.g.:
|
||||
- `run` → `exit 0 · <first line>` (parse "exit status N" / "ERROR")
|
||||
- `get_entity` → `host:hubris (healthy)`; `get_health_summary` → `healthy X · degraded Y · down Z`
|
||||
- `list_entities`/`list_lxcs` → `N entities`; `get_relations` → `N relations`
|
||||
- `search_knowledge` → `N results`; `upsert_knowledge` → `recorded document:…`
|
||||
- `update_plan_step` → `step <seq> → <status>`; `propose_plan` → `N steps`
|
||||
- default → first non-empty line of stringified result (≤80ch); `done` if empty
|
||||
- Live `run` output: while streaming, the line auto-expands a pinned-tail mini
|
||||
pane (reuse the `liveOutput` path from `toolsWithLive`).
|
||||
- Click → expand raw args/result (border-driven `<pre>`, cyberspace-square).
|
||||
- Border-driven, no rounded/shadow (per `border_driven_language`).
|
||||
|
||||
### D3 — Live plan checklist (TodoWrite-style)
|
||||
On the **running/last** turn, render the current-generation `planSteps`
|
||||
(already generation-aware via `workspace.ts`) as a checklist: pending = hollow,
|
||||
running = spinner + highlight, done = ✓, failed = ✗, blocked = pause. Steps
|
||||
check off live as `plan.step.*` events land. This is the unified timeline's
|
||||
plan view, moved inline and scoped to the active turn. Past turns render only
|
||||
their tool lines + text (the plan is session-level; the running turn carries
|
||||
its current state, mirroring how TodoWrite re-displays state each turn). On a
|
||||
terminal task state (`done`/`failed`), the checklist collapses to one line:
|
||||
`Plan complete — N steps` / `Plan failed — step K`.
|
||||
|
||||
### D4 — Rail → graph only
|
||||
`TaskContextPanel`: remove the Activity pane and the `UnifiedTimeline` import;
|
||||
the panel becomes the Scope graph full-height (keep the collapsible "Scope"
|
||||
header + the `nowTouching` strip). The graph is now the rail's entire job, so
|
||||
auto-fit (D6) matters more. `activityLog*` stores remain imported only where
|
||||
the inline stream/mascot need them.
|
||||
|
||||
### D5 — Cyberspace cohesion of the stream
|
||||
Apply alongside the rewrite so the new inline view is on-system from day one:
|
||||
- Transcript → **terminal log rows** (square, full-width, `YOU`/`NOMOS`
|
||||
role-tags, hairline `divide-y` separators; no bubbles, no soft shadow).
|
||||
Delete `.user-msg { box-shadow }`.
|
||||
- Tool lines + expanded `<pre>`: border-driven, square, opaque.
|
||||
- Composer: opaque `bg-background`, square (remove `rounded-2xl`/`bg-card/50`).
|
||||
- Rewrite the stale "Art Nouveau" `<style>` comments → "cyberspace/terminal".
|
||||
- Per `central_css_override`: drive surface styling centrally in `app.css`
|
||||
where it's a primitive concern; no ad-hoc `rounded-*`/`shadow-*`/`backdrop-blur`.
|
||||
|
||||
### D6 — Graph auto-fit + drag-pan (`SessionGraph.svelte`) (carried over)
|
||||
- Wrap nodes+links in `<g transform="translate(tx,ty) scale(s)">`; fit the bbox
|
||||
of all nodes (radius + label + padding) into `cw`/`ch`; cap `s ∈ [0.2, 2.5]`.
|
||||
- Re-fit on: mount, node-set change, container resize, sim-settle
|
||||
(`alpha > 0.05`), background double-click. **Not** every tick (fights pan).
|
||||
A `userPanned` flag pauses auto-follow after a manual pan until next
|
||||
membership/resize/double-click.
|
||||
- Background drag = pan (`tx`/`ty`); node drag converts screen→graph via the
|
||||
inverse transform before setting `fx`/`fy`. Dot-grid stays in screen space.
|
||||
- Keep: open-on-click, `touched` pulse, health-diff label, selection ring.
|
||||
Respect `scrollIntoView` pitfall (transform, not scroll).
|
||||
|
||||
## Phased task list (each independently shippable; all frontend)
|
||||
|
||||
- **P1 — Inline progressive stream.** `TurnTrace.svelte` + `ToolLine.svelte`;
|
||||
wire into `ChatThread` per turn; "Thinking" line; tool→step grouping via
|
||||
activity-log `stepSeq` matched by tool id; keep `toolsWithLive` for `run`.
|
||||
- **P2 — Live plan checklist.** Inline current-gen `planSteps` on the running
|
||||
turn; collapse-to-summary at terminal state.
|
||||
- **P3 — Rail → graph only.** Strip Activity pane + `UnifiedTimeline` from
|
||||
`TaskContextPanel`; verify no other importers (grep: only TaskContextPanel).
|
||||
- **P4 — Cyberspace cohesion.** Terminal log rows; remove rounded/shadow/
|
||||
translucency; square composer; centralize in `app.css`; fix stale comments.
|
||||
- **P5 — Graph auto-fit + drag-pan.** D6.
|
||||
- **Polish (small, frontend-only):** per-message/tool **copy**; **scroll-to-
|
||||
bottom** button (uses `container.scrollTo`, never `scrollIntoView`).
|
||||
|
||||
## Plumbing specifics (grounded, no backend)
|
||||
|
||||
- New `toolResultSummary(t: ToolCallResult): string` in `activity.ts`, beside
|
||||
`toolActivityLabel`. Per-name switch (D2 list), graceful fallback.
|
||||
- Tool→step grouping: build `id → stepSeq` from the activity log once per turn;
|
||||
tools with no step render as orphans.
|
||||
- Reuse: `planSteps` (generation-aware), `indicatorLabel`, `toolsWithLive`,
|
||||
`toolActivityLabel`, `liveOutput` streaming path.
|
||||
|
||||
## Constraints honored (saved decisions)
|
||||
|
||||
- `design_system.central_css_override`, `border_driven_language`: square,
|
||||
hairline, opaque, focus-by-color, no soft shadows/glows.
|
||||
- `chat_thread.pane_layout`: dynamic status (Thinking line, live checklist)
|
||||
lives in the **message Pane**, never the input Pane.
|
||||
- `wmkit.scrollintoview_reflow_pitfall`: `container.scrollTo` for scroll-to-
|
||||
bottom; transform (not scroll) for graph pan.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Removing the rail timeline loses the "overview" view.** Mitigation: the
|
||||
inline checklist + per-turn tool lines carry the same info progressively; the
|
||||
graph still shows fleet scope. If operators miss the overview, a collapsed
|
||||
"full timeline" can return as a toggle (follow-up).
|
||||
- **Auto-fit vs manual pan** — handled by `userPanned` + settle-alpha gate.
|
||||
- **Inline stream length on long turns** (15–27 min, many tools) — progressive
|
||||
lines can get long; mitigate by auto-collapsing finished steps (keep the
|
||||
running step + its tools expanded, prior steps as one-line summaries).
|
||||
- **Best-effort result summaries** may misformat unusual payloads — fallback is
|
||||
always a truncated raw line + expandable raw detail, never a blank.
|
||||
|
||||
## Validation
|
||||
|
||||
- `npm run lint`, `tsc --noEmit` (no NEW errors beyond the known baseline in
|
||||
`ui/*`, `oidc.ts`, `windows.ts`, `workspace.ts`), `vite build`, `vitest`
|
||||
(add a `toolResultSummary` unit test per tool name + fallback).
|
||||
- Manual matrix: (a) start a long task → Thinking line → plan checklist
|
||||
appears and checks off live → each tool streams as its own line with a
|
||||
one-line summary → text streams; (b) reload mid-turn → working still shows;
|
||||
(c) `run` tool → live output pins to tail then collapses to summary; (d)
|
||||
graph auto-fits at settle + on new entity + drag-pan + double-click reset;
|
||||
(e) no rounded/soft-shadow remains on chat surfaces; (f) rail shows graph only.
|
||||
|
||||
## Deferred (later phases, after this lands + validates)
|
||||
|
||||
- **Edit-and-resubmit** — `truncateFrom` store method + `POST /sessions/{id}/edit`
|
||||
(extract `streamTurn` from `handleChat`); reuse `reopenSession` (already
|
||||
exists, store.go:838 — marks prior `session_plan_steps` `replaced`, clears
|
||||
outcome) for the reset. Reject edit while the gate is busy (HTTP 409); edit
|
||||
cannot queue (truncation must be atomic). Regenerate = no-op-edit case.
|
||||
- **@entity mentions** — small `GET /api/v1/entities/search?q=` + composer
|
||||
autocomplete inserting `type:name` slugs the agent/graph already parse.
|
||||
- **Attachments** — multipart upload + `agent_attachments` table + configured
|
||||
`OIKOS_ATTACHMENTS_DIR` (explicit volume, not relative) + capped text inlining.
|
||||
- **Continue button** — needs `/resume` to `reopenSession` first for terminal
|
||||
sessions (today `/resume` does not reopen `done`/`failed`; `handleChat`'s
|
||||
follow-up path does). Small backend tweak.
|
||||
- **Image vision** pending provider confirmation.
|
||||
|
||||
## Out of scope / follow-ups
|
||||
|
||||
- A collapsible "full timeline" overview toggle if the rail removal is missed.
|
||||
- `read_attachment` MCP tool (lazy full-content fetch, lower context than inlining).
|
||||
- Oldest-first timeline toggle / per-tool `tool.*` events for background turns.
|
||||
145
plans/done/2026-08-04-hermes-mcp-client-integration.md
Normal file
145
plans/done/2026-08-04-hermes-mcp-client-integration.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 2026-08-04 — Hermes MCP client integration: native tool surface for oikos
|
||||
|
||||
**Status:** Plan.
|
||||
**Context:** Hermes Agent (mac-mini workstation) now connects to oikos's MCP server
|
||||
as a native MCP client (`mcp_servers.oikos` in `~/.hermes/config.yaml`). All 37+ MCP
|
||||
tools are available as `mcp__oikos__*` first-class Hermes tool calls — no more raw
|
||||
curl with batch-initialize SSE parsing. The integration works; this plan tightens the
|
||||
remaining seams.
|
||||
|
||||
**Trigger:** First-use retrospective identified three areas that make the integration
|
||||
harder to use than it should be.
|
||||
|
||||
---
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
The oikos MCP server (`internal/mcp/`) speaks Streamable HTTP at
|
||||
`https://mcp.hubris.network/mcp`. Hermes Agent's native MCP client connects to it on
|
||||
startup, discovers tools, and registers them as callable functions. This replaces the
|
||||
previous pattern where agents fired raw curl requests with batch `initialize` +
|
||||
`tools/call` envelopes.
|
||||
|
||||
Three friction points observed:
|
||||
|
||||
- **No lightweight connectivity check.** The `/healthz` HTTP endpoint exists but isn't
|
||||
exposed at the MCP protocol layer. An agent that wants to verify the MCP server is
|
||||
reachable must call a real tool (e.g. `list_entities` with a limit of 1) — every call
|
||||
carries the Streamable HTTP session-initialization overhead.
|
||||
- **Bearer token in plaintext.** `~/.hermes/config.yaml` stores the token directly in the
|
||||
`mcp_servers.oikos.headers.Authorization` value. Hermes does not support env-var
|
||||
interpolation in MCP server configs, so the token can't live only in `.env`.
|
||||
- **Zero-visibility streaming overhead.** Streamable HTTP batches `initialize` +
|
||||
`tools/call` per request. This adds ~2KB of transport per tool call that the agent
|
||||
never sees. For a single `get_health_summary` call this is negligible; for a 10-tool
|
||||
exploration pass it's 20KB of invisible overhead.
|
||||
|
||||
---
|
||||
|
||||
## 2. Changes
|
||||
|
||||
### I — MCP health/ping tool (`mcp__oikos__ping`)
|
||||
|
||||
**Why:** Agents need a zero-cost connectivity check before calling production tools.
|
||||
Currently every check incurs the full Streamable HTTP initialize + tools/call round-trip.
|
||||
|
||||
**What:**
|
||||
|
||||
Add a `ping` tool that returns `{"ok": true, "server": "oikos", "version": "dev"}`.
|
||||
No arguments. No DB hit. No auth check (already protected by the MCP transport's auth
|
||||
layer — the request won't arrive if the bearer token is missing).
|
||||
|
||||
```go
|
||||
// internal/mcp/tools.go
|
||||
{
|
||||
Name: "ping",
|
||||
Description: "Lightweight connectivity check. Returns immediately with server identity, no DB hit.",
|
||||
InputSchema: jsonschema.Must(nil), // no params
|
||||
Handler: func(ctx context.Context, args json.RawMessage, caller CallerInfo) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"ok":true,"server":"oikos","version":"` + version.Version + `"}`), nil
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Risk class:** read-only. No auth, no DB, no state. Auto-approves.
|
||||
|
||||
**Test:** `hermes mcp test oikos` (from the Hermes CLI) verifies MCP server reachability
|
||||
independently; the `ping` tool gives agent code the same signal programmatically.
|
||||
|
||||
### II — Tool name documentation in server metadata
|
||||
|
||||
**Why:** Hermes prefixes MCP tools as `mcp_{server}_{tool}`, so `get_health_summary`
|
||||
becomes `mcp__oikos__get_health_summary`. Agents discover tool names at runtime via
|
||||
`tools/list`, but there's no short summary of what each tool group does that survives
|
||||
into the MCP tool description.
|
||||
|
||||
**What:**
|
||||
|
||||
Audit and tighten every tool's `Description` field in `internal/mcp/tools.go` so the
|
||||
first 8–12 words are a searchable one-liner an agent can pattern-match against.
|
||||
Current descriptions that are vague or redundant get a prefix rewrite:
|
||||
|
||||
| Tool | Current description | Revised |
|
||||
|------|-------------------|---------|
|
||||
| `get_entity` | "Get entity metadata" | "Look up one entity by slug or UUID — type, state, attributes, health" |
|
||||
| `list_entities` | "List entities" | "Browse entities by type, state, or name substring — paginated" |
|
||||
| `upsert_knowledge` | "Record what you learned" | "Write a document/investigation/runbook to the knowledge graph — idempotent" |
|
||||
| `run` | "Run ANY shell command" | "Execute a shell command on any host/LXC/VM — auto-classified by risk" |
|
||||
|
||||
Existing tools pass through unchanged if their description is already crisp. ~15 tools
|
||||
get description rewrites.
|
||||
|
||||
**Risk class:** read-only (config change). No runtime effect.
|
||||
|
||||
### III — Env-var interpolation docs for Hermes config (oikos-side documentation)
|
||||
|
||||
**Why:** The bearer token lives in `~/.hermes/config.yaml` in plaintext because Hermes
|
||||
does not support `${VAR}` interpolation in MCP server configs. This is a Hermes
|
||||
upstream feature request, not an oikos change — but oikos should document the
|
||||
workaround and track the upstream ask.
|
||||
|
||||
**What:**
|
||||
|
||||
Add a `### Hermes MCP client` subsection to `docs/infrastructure/mcp-server.md` (or
|
||||
create it if it doesn't exist) that covers:
|
||||
|
||||
1. The config block to add to `~/.hermes/config.yaml` (already done — record it
|
||||
for the next person).
|
||||
2. The token exposure caveat: Hermes doesn't support env-var interpolation in
|
||||
`mcp_servers` `headers` yet (upstream issue nousresearch/hermes-agent#TODO — file
|
||||
once).
|
||||
3. Workaround: `hermes config set security.redact_secrets true` (already default) so
|
||||
the token value is stripped from tool output and logs even if it appears in
|
||||
diagnostic text.
|
||||
4. How to verify the connection: `hermes mcp list` → `hermes mcp test oikos`.
|
||||
|
||||
**Risk class:** docs-only.
|
||||
|
||||
---
|
||||
|
||||
## 3. Open questions
|
||||
|
||||
| Question | Decision |
|
||||
|----------|----------|
|
||||
| Should `ping` bypass auth entirely or still require a valid bearer token? | **Still requires auth.** The MCP transport layer validates the token before routing to `ping` — no special treatment needed. If the token is missing, the request never reaches the handler. |
|
||||
| Who files the Hermes upstream feature request for `${VAR}` interpolation? | **Oikos operator** (dtoro). The need is specific to this deployment. File at https://github.com/NousResearch/hermes-agent/issues. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Not doing (yet)
|
||||
|
||||
- **Persistent MCP sessions** — Streamable HTTP stateless mode is fine for the
|
||||
current tool-call volume (~1–5 calls per agent turn). Persistent sessions would
|
||||
save ~2KB per call but add connection lifecycle complexity. Revisit if per-turn
|
||||
tool calls exceed 20.
|
||||
- **`tools/list` caching** — Hermes already caches tool discovery at session start.
|
||||
The 37-tool list is ~4KB; caching adds complexity for negligible savings.
|
||||
|
||||
---
|
||||
|
||||
## 5. Verification
|
||||
|
||||
1. `curl -X POST https://mcp.hubris.network/mcp ... -d '...ping...'` returns `{"ok":true,"server":"oikos","version":"dev"}`
|
||||
2. `hermes mcp list` shows `ping` among oikos tools
|
||||
3. `hermes doctor` passes
|
||||
4. Tool descriptions are crisp: `hermes mcp list` output for oikos shows prefixed summaries
|
||||
369
plans/done/2026-08-04-session-audit-agent-reliability.md
Normal file
369
plans/done/2026-08-04-session-audit-agent-reliability.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# 2026-08-04 — Session audit: agent reliability, plan system, and learning loop gaps
|
||||
|
||||
**Status:** Done — all 12 tasks implemented, tested, deployed in v0.21.0. Verified in production
|
||||
with live session tests (classifications, feedback, token tracking, execution linkage all confirmed).
|
||||
**Reviewed sessions:** Past 5 completed plus ZimaOS continuation (268895a5)
|
||||
**Method:** Direct Postgres read of `agent_sessions`/`agent_messages`/
|
||||
`agent_activity`/`session_plan_steps`/`executions`/`classifications`/`feedback`/
|
||||
`patterns`/`skills`/`approvals`/`nomos_plan_executions`/`audit_log` on the prod
|
||||
mac-mini. Cross-referenced with `internal/audit/`, `internal/mcp/`,
|
||||
`internal/httpapi/`, `internal/policy/`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sessions audited
|
||||
|
||||
| # | Session | Turns | Run calls | Failures | Plan steps (done/total) | Duration | Outcome |
|
||||
|---|---------|-------|-----------|----------|-------------------------|----------|---------|
|
||||
| S1 | Pocket-pascal deploy (a433b386) | 8 | 111 | 1 | 10/11 | 1.5h | Success (truncated by turn limit) |
|
||||
| S2 | SSH re-investigation (6f0ade08) | 4 | 42 | 0 | 0/4 | 11m | Success (all steps replaced) |
|
||||
| S3 | Webhook HMAC (0f509508) | 2 | 40 | 0 | 5/5 | 2m | Success (clean; best session) |
|
||||
| S4 | Check scripts (d458a5f8) | 23 | 96 | 4 | 0/14 | 2h | Success (3 plan gens; 0 steps done) |
|
||||
| S5 | ZimaOS outage (268895a5) | 18+10 | 209 | 8 | 0/5 | 2h30m | **Marked success; dashboard still broken** |
|
||||
|
||||
**Headline:** 85% session success rate, but plan adherence is ~38% (15/39 steps
|
||||
ever reached `done`). The ZimaOS session was the worst: marked `success` while
|
||||
the dashboard was still down, then burned 81 more calls and 30 minutes chasing
|
||||
irrelevant DHCP reservations.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cross-cutting findings
|
||||
|
||||
### F1 — MCP 30s client timeout kills every long-running command (P0)
|
||||
|
||||
All **9** `run` failures across the audit are `Post "http://api:8090/mcp":
|
||||
context deadline exceeded` at exactly 30s. Commands with `sleep 30`, `qm
|
||||
shutdown`+wait, or async poll loops always hit this. The agent retries with
|
||||
longer sleeps and hits the same wall.
|
||||
|
||||
**Root cause:** `cmd/nomos` MCP client uses a 30s timeout; the `run` tool
|
||||
blocks synchronously waiting for command completion. No async path exists for
|
||||
long-running commands.
|
||||
|
||||
### F2 — Premature success: `complete_task` fires before the goal is actually met (P0)
|
||||
|
||||
Session 268895a5 called `complete_task(success)` at 18:29 with summary "zimaos
|
||||
returns 200." The endpoint was serving ttyd (terminal), not the ZimaOS
|
||||
dashboard. The agent conflated "HTTPS 200" with "dashboard works." The user had
|
||||
to resume the session.
|
||||
|
||||
**Root cause:** No pre-completion validation. The agent can mark success with a
|
||||
summary that doesn't match reality. `complete_task` is a write-and-forget
|
||||
operation with no state check.
|
||||
|
||||
### F3 — Wrong target: `run` doesn't validate the target can execute the command (P0)
|
||||
|
||||
In the ZimaOS continuation, `qm stop 100 --skiplock` was executed on
|
||||
`lxc:dns`. The command failed (`qm: command not found`) because the agent
|
||||
copied the command from a previous run but forgot to change the target.
|
||||
Similarly, `cmd/nomos` tried `docker exec` on `host:hubris` (no docker).
|
||||
|
||||
**Root cause:** `run(target, command)` in `internal/mcp/server.go` doesn't
|
||||
validate that the target type can execute the given command. A simple
|
||||
allowlist would catch `qm`/`pct`/`pvesh` on non-host targets.
|
||||
|
||||
### F4 — Plan system is decorative: 62% of steps never reach `done` (P1)
|
||||
|
||||
Across 5 sessions: 39 plan steps. 24 (62%) were `replaced`, 15 (38%) reached
|
||||
`done`. Session d458a5f8 had 3 complete plan regenerations with **zero**
|
||||
completed steps. The agent replaces plans instead of completing or explicitly
|
||||
skipping steps.
|
||||
|
||||
**Root cause:** Plan steps carry status (`pending`/`running`/`done`/`failed`/
|
||||
`skipped`/`blocked`/`replaced`) but `replaced` has no `replaced_reason`
|
||||
field. The model can silently replace every step and the system doesn't flag
|
||||
it. No constraint ties `propose_plan` to existing plan state.
|
||||
|
||||
### F5 — No plan on session resume: continuation sessions run ad-hoc (P1)
|
||||
|
||||
The ZimaOS continuation (18:29→19:01) had 81 calls with **zero**
|
||||
`propose_plan` calls. The agent ran ad-hoc tool calls with no structure.
|
||||
|
||||
**Root cause:** When a session resumes, the `must_have_plan` guard is already
|
||||
satisfied by the old (completed) plan. The agent doesn't re-plan on resume.
|
||||
|
||||
### F6 — Scope expansion / rabbit holes: agent chases irrelevant sub-goals (P2)
|
||||
|
||||
In the ZimaOS continuation, the agent spent ~40 calls trying to fix Technitium
|
||||
DHCP reservations — a completely different subsystem from the goal ("make the
|
||||
dashboard reachable"). The ZimaOS dashboard hadn't started since July 19
|
||||
(3-week-old issue), making the DHCP reservation effort moot. The agent never
|
||||
surfaced a question like "This is pre-existing — should I still fix DHCP?"
|
||||
|
||||
**Root cause:** No scope gate. When the agent pivots to a subsystem unrelated
|
||||
to the stated goal, nothing stops it. The `session_questions` mechanism exists
|
||||
(2 calls in 193 sessions) but the model never uses it.
|
||||
|
||||
### F7 — Command generation errors: malformed bash from the LLM (P2)
|
||||
|
||||
In the ZimaOS continuation, the agent generated:
|
||||
- `head - n` instead of `head -n` → bash syntax error
|
||||
- `echo "---" && \n curl ...` → literal `\n` in command → ambiguous redirect
|
||||
|
||||
**Root cause:** The LLM generates bash commands inline in content blocks. No
|
||||
syntax validation, no escape-character handling. The `run` tool should reject
|
||||
malformed commands before execution.
|
||||
|
||||
### F8 — Learning pipeline completely empty (P4)
|
||||
|
||||
| Table | Rows |
|
||||
|-------|------|
|
||||
| `classifications` | **0** |
|
||||
| `feedback` | **0** |
|
||||
| `patterns` | **0** |
|
||||
| `skills` | **0** |
|
||||
|
||||
Despite 1,884 executions and 263 approvals, the system learns nothing from
|
||||
outcomes. The ZimaOS session discovered that the dashboard hadn't started since
|
||||
July 19 — this was never persisted. The HMAC trailing-newline discovery was
|
||||
persisted manually; if the agent forgot `upsert_knowledge`, it would be lost.
|
||||
|
||||
### F9 — Observability gaps (P3)
|
||||
|
||||
| Gap | Detail |
|
||||
|-----|--------|
|
||||
| Token tracking | `agent_activity.token_count` is NULL for every row |
|
||||
| Execution linkage | `nomos_plan_executions` is empty; `audit_log.session_id` is null |
|
||||
| Plan quality | No metric for step completion rate (currently 38%) |
|
||||
| MCP timeout rate | No counter for `run` calls that hit the client timeout |
|
||||
|
||||
### F10 — Execution success rate is 74% (P2)
|
||||
|
||||
1,884 executions: 1,400 completed (74%), 295 failed (15.6%), 152 cancelled
|
||||
(8%), 37 denied (2%). One in four execution attempts doesn't complete.
|
||||
|
||||
---
|
||||
|
||||
## 3. Improvement plan (ordered by impact/effort)
|
||||
|
||||
### Task 1 — Prevent premature `complete_task(success)` *(fixes F2)*
|
||||
|
||||
**`internal/mcp/tools.go`** — In the `complete_task` handler, when
|
||||
`outcome=success`: require the summary field to contain a verifiable state
|
||||
assertion. Minimum: if the goal mentions a URL, check that the summary doesn't
|
||||
contradict known state. Lightweight: log a warning if the summary says "returns
|
||||
200" but the last `ping_service` or `run` result says otherwise.
|
||||
|
||||
**Coach:** `nomos/SOUL.md` — explicit rule: *"Before calling
|
||||
complete_task(success), restate the user's original goal in your own words and
|
||||
verify each condition. If any condition is 'probably works' rather than
|
||||
'verified,' ask the operator or set outcome=partial."*
|
||||
|
||||
### Task 2 — Target validation in `run` *(fixes F3)*
|
||||
|
||||
**`internal/mcp/server.go`** — In the `run` handler, before dispatching:
|
||||
validate that the command prefix matches the target type.
|
||||
|
||||
```
|
||||
pct/qm/pvesh/iptables → only host:* targets
|
||||
systemctl/docker → host:* or lxc:* targets
|
||||
curl/nmap/ss → any target
|
||||
```
|
||||
|
||||
If mismatched, return a clear error: *"Cannot run `qm` on lxc:dns — `qm` is a
|
||||
Proxmox host command. Use target host:hubris or host:strong."* Do not classify
|
||||
or execute.
|
||||
|
||||
**Test:** `TestClassifyCommand_WrongTarget` → commands with host-only prefixes
|
||||
on LXC targets return error without execution.
|
||||
|
||||
### Task 3 — Raise MCP client timeout; add async path for long-running commands *(fixes F1)*
|
||||
|
||||
**`cmd/nomos`** — Raise the MCP client timeout from 30s to 120s.
|
||||
|
||||
**`internal/mcp/server.go`** — For `run` commands that the classifier
|
||||
determines will exceed the client timeout (presence of `sleep`, `wait`,
|
||||
`timeout` in the command), return immediately with an `execution_id` and status
|
||||
`running`. The agent already has `get_execution_status` — use it:
|
||||
|
||||
1. Classify the command; if it contains `sleep`, `wait`, or shell constructs
|
||||
that imply polling, flag it as `async_potential`.
|
||||
2. Start the command, return the `execution_id` immediately.
|
||||
3. Agent polls with `get_execution_status(execution_id)`.
|
||||
4. If the client timeout is hit mid-poll, the execution continues on the server
|
||||
— it's not lost.
|
||||
|
||||
**Test:** `run` with `sleep 60; echo done` on host:hubris → returns
|
||||
immediately (not 30s timeout), `get_execution_status` eventually returns
|
||||
`completed`.
|
||||
|
||||
### Task 4 — Plan step integrity: require `replaced_reason` on replacement *(fixes F4)*
|
||||
|
||||
**`session_plan_steps` migration** — Add `replaced_reason TEXT` column.
|
||||
|
||||
**`cmd/nomos`** — When the agent emits `update_plan_step` with
|
||||
`status=replaced`, require a non-empty `replaced_reason`. Valid reasons:
|
||||
`wrong_diagnosis`, `scope_change`, `blocked`, `superseded`, `operator_override`.
|
||||
|
||||
**Coach:** `nomos/SOUL.md` — explicit rule: *"Complete (status=done) or
|
||||
explicitly skip (status=skipped) steps. Use status=replaced only when the
|
||||
entire plan generation is wrong; include the reason. Replacing all steps with
|
||||
no reason is a session-quality violation."*
|
||||
|
||||
### Task 5 — Force `propose_plan` on session resume *(fixes F5)*
|
||||
|
||||
**`cmd/nomos`** — When a session with status `done` or `failed` receives a new
|
||||
user message, reset the plan state: clear step status, require a new
|
||||
`propose_plan` call before any `run` calls. The "must have plan" guard should
|
||||
consider the resumed session as plan-less until a fresh `propose_plan` is
|
||||
called.
|
||||
|
||||
**Guard:** `set_goal` + `propose_plan` must be called before any `run` in a
|
||||
resumed session. Reuse the existing "No plan — call set_goal then propose_plan"
|
||||
error from `internal/mcp/server.go`.
|
||||
|
||||
### Task 6 — Scope gate: surface `session_questions` on context switch *(fixes F6)*
|
||||
|
||||
**Coach:** `nomos/SOUL.md` — explicit rule: *"Before pivoting to a subsystem
|
||||
not mentioned in the user's goal, ask via session_questions. Example: 'The
|
||||
dashboard logs show it hasn't started since July 19. Do you want me to debug
|
||||
the dashboard service itself [A], skip it and just stabilize the IP [B], or
|
||||
stop here [C]?'"*
|
||||
|
||||
**`cmd/nomos` prompt** — Add to the system prompt: *"When the investigation
|
||||
leads to a subsystem or root cause unrelated to the expressed goal, surface a
|
||||
session_question before taking action."*
|
||||
|
||||
### Task 7 — Auto-upsert knowledge on session close *(fixes F8)*
|
||||
|
||||
**`cmd/nomos`** — On `complete_task` (any outcome: success, partial, failure),
|
||||
auto-generate a knowledge entry:
|
||||
|
||||
```
|
||||
title: "<date>: <goal summary>"
|
||||
content: "## Outcome\n<outcome>\n## Root cause\n<extracted>\n## What was done\n<summary>\n## What was left\n<unresolved>"
|
||||
tags: [session:<id>]
|
||||
about: [entities involved]
|
||||
```
|
||||
|
||||
This ensures every session leaves a trace regardless of whether the agent
|
||||
remembered to call `upsert_knowledge`.
|
||||
|
||||
### Task 8 — Token tracking *(fixes F9)*
|
||||
|
||||
**`cmd/nomos`** — After each LLM call, extract `usage.prompt_tokens`,
|
||||
`usage.completion_tokens`, `usage.total_tokens` from the response and write to
|
||||
`agent_activity.token_count`. Currently the field exists but is never populated
|
||||
(NULL for all rows).
|
||||
|
||||
### Task 9 — Execution linkage *(fixes F9)*
|
||||
|
||||
**`internal/mcp/server.go`** — When `run` creates an execution, write a row
|
||||
into `nomos_plan_executions` linking `session_id`, `plan_step_seq`, and
|
||||
`execution_id`.
|
||||
|
||||
**`internal/mcp/server.go`** — Pass `session_id` (from MCP request headers)
|
||||
into `audit_log` writes. Currently `audit_log.session_id` is NULL — the
|
||||
`createAuditLog` function in `internal/httpapi/impl.go` receives the
|
||||
correlation_id but not the session_id from the MCP path.
|
||||
|
||||
### Task 10 — Plan quality metric *(fixes F9)*
|
||||
|
||||
**`cmd/nomos`** — At session close, compute: `completed_steps /
|
||||
total_steps_per_plan` (currently ~38%). Log as a metric or write as a session
|
||||
attribute. Track over time to measure plan-adherence improvements from Tasks
|
||||
4+5.
|
||||
|
||||
### Task 11 — Auto-classify every `run` call *(fixes F8)*
|
||||
|
||||
**`internal/mcp/server.go`** — The `run` handler already calls the classifier
|
||||
(`classifyCommand` in `internal/policy/command.go`) to determine risk_class and
|
||||
approval route. Write the result to the `classifications` table. Currently the
|
||||
table is empty (0 rows) despite 1,884 executions being classified.
|
||||
|
||||
### Task 12 — Auto-feedback on session close *(fixes F8)*
|
||||
|
||||
**`cmd/nomos`** — On `complete_task`, generate a `feedback` entry:
|
||||
|
||||
```
|
||||
session_id: <id>
|
||||
outcome: <outcome>
|
||||
observation: <summary>
|
||||
lesson: <extracted from complete_task.summary>
|
||||
side_effects: <entities created/modified during session>
|
||||
```
|
||||
|
||||
**`cmd/oikos`** — Add a daily cron or scheduler job that reads recent
|
||||
`feedback` entries and extracts `patterns` (recurring root causes, same-fix
|
||||
applied multiple times, known-broken services). Seed the pattern table.
|
||||
|
||||
### Task 13 — Command syntax validation in `run` *(fixes F7)*
|
||||
|
||||
**`internal/mcp/server.go`** — Before executing a `run` command, do
|
||||
lightweight bash syntax validation:
|
||||
|
||||
```
|
||||
- Reject literal \n in commands (should be ; or &&)
|
||||
- Reject commands where the last line ends with \ (backslash-continuation)
|
||||
but no next line
|
||||
- Warn on common typos: "head - n", "grep - i", spaces before flags
|
||||
- Reject `&& \n` patterns (the LLM sometimes inserts literal \n between && chains)
|
||||
```
|
||||
|
||||
### Task 14 — Stuck-session reaping (from prior plan; re-confirmed)
|
||||
|
||||
This session exhibited the same idle zombie pattern (23da10db — no closed_at,
|
||||
status `failed` but outcome `failure`). Task 5 from the 2026-08-03 plan is
|
||||
still open. Copying here for completeness.
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended sequence
|
||||
|
||||
```
|
||||
P0 (blocks operational waste):
|
||||
1 → 2 → 3
|
||||
|
||||
P1 (fixes plan architecture):
|
||||
4 → 5
|
||||
|
||||
P2 (cognitive guardrails):
|
||||
6 → 7 → 13
|
||||
|
||||
P3 (observability):
|
||||
8 → 9 → 10
|
||||
|
||||
P4 (learning loop):
|
||||
11 → 12
|
||||
```
|
||||
|
||||
Sequence rationale: Tasks 1-3 stop the worst outcomes (premature success,
|
||||
wrong-target execution, MCP timeouts). Tasks 4-5 make the plan system actually
|
||||
useful instead of decorative. Tasks 6-7 add guardrails that prevent the ZimaOS
|
||||
rabbit-hole class of failure. Tasks 8-10 give us visibility into whether any of
|
||||
the previous tasks are working. Tasks 11-12 close the learning loop.
|
||||
|
||||
---
|
||||
|
||||
## 5. Validation
|
||||
|
||||
| Task | Test |
|
||||
|------|------|
|
||||
| 1 | Session with goal "make X reachable" where last ping shows 502 → `complete_task(success)` is rejected or warns |
|
||||
| 2 | `run("lxc:dns", "qm stop 100")` → error: "qm is a Proxmox host command" |
|
||||
| 3 | `run` with `sleep 45; echo done` → returns execution_id immediately, `get_execution_status` shows final result |
|
||||
| 4 | `update_plan_step(status=replaced)` with no reason → rejected; with reason → accepted |
|
||||
| 5 | Resumed session calls `run` before `propose_plan` → blocked: "No plan — call propose_plan" |
|
||||
| 6 | Agent pivots to unrelated subsystem → `session_questions` is called before action |
|
||||
| 7 | `complete_task` → knowledge entry created automatically with session link |
|
||||
| 8 | `agent_activity.token_count` is non-NULL after any LLM call |
|
||||
| 9 | `nomos_plan_executions` has rows linking session + step + execution |
|
||||
| 10 | Session close writes `plan_adherence` attribute (step-completion %) |
|
||||
| 11 | `classifications` table has 1 row per `run` call with risk_class + route |
|
||||
| 12 | `complete_task` → auto `feedback` entry; daily pattern job finds recurring issues |
|
||||
| 13 | `run` with `head - n /etc/hosts` → rejected with clear error about malformed command |
|
||||
|
||||
---
|
||||
|
||||
## 6. Out of scope / open questions
|
||||
|
||||
- Whether to raise `auto_act` from `off` for `reversible_low` actions (separate
|
||||
policy decision; would reduce approval pileup without code changes).
|
||||
- Whether to add a `delete_entity` MCP tool for lifecycle management (separate
|
||||
from this reliability plan).
|
||||
- The exact TTL for stuck-session reaping (30 min recommended, confirmed in
|
||||
2026-08-03 plan).
|
||||
- Whether `run` async mode should be opt-in (command contains sleep/wait) or
|
||||
universal (every run returns immediately, agent always polls). Recommend
|
||||
opt-in for now — most commands complete in <5s.
|
||||
304
plans/done/2026-08-04-unified-mcp-agents.md
Normal file
304
plans/done/2026-08-04-unified-mcp-agents.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# 2026-08-04 — Unified plan: external MCP agents + Nomos reliability
|
||||
|
||||
**Status:** Complete. All 5 stages implemented (2026-08-04).
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Two audiences, two gaps:
|
||||
|
||||
| Audience | Current state | Goal |
|
||||
|---|---|---|
|
||||
| **Nomos** (internal agent) | 85% session success, but 38% plan adherence, 9 timeout failures, premature success, empty learning table | Reliable, self-correcting, leaves a trace |
|
||||
| **External agents** (Claude, Goose, etc.) | 40 MCP tools — heavy on observe, light on act. Can't manage signals, checks, executions, or knowledge beyond `upsert`. | Full Oikos surface: observe + act + curate |
|
||||
|
||||
The two plans share infrastructure (`internal/mcp/server.go`) and have cross-task
|
||||
dependencies. This document merges them into one sequenced plan.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cross-plan dependencies
|
||||
|
||||
Three audit tasks are **prerequisites** for external-agent mutation tools:
|
||||
|
||||
| Audit task | Enables | Why |
|
||||
|---|---|---|
|
||||
| **Task 3** — async `run` + timeout | `cancel_execution`, `list_executions` with live status | Without async `run`, every command >30s hits a client timeout. The agent loses track of the execution and can't cancel it. |
|
||||
| **Task 9** — execution linkage | `list_executions` filtered by session/entity | `nomos_plan_executions` table is empty; `audit_log.session_id` is NULL. Without linkage, execution queries are blind. |
|
||||
| **Task 2** — target validation in `run` | All Phase 2 mutation tools | Can't let external agents mutate state on targets that can't execute the command (audit found `qm` run on `lxc:dns`). |
|
||||
|
||||
Two items were **dropped** from the original MCP expansion:
|
||||
|
||||
- **`approve_execution` / `deny_execution`** — separation-of-duties violation. An
|
||||
MCP agent approving its own queued commands breaks the approval model. The
|
||||
correct fix is raising `auto_act` for `reversible_low` (policy change, zero
|
||||
code — listed in the audit plan's out-of-scope). The existing assent-window
|
||||
path in `run()` already auto-approves reversible-low commands.
|
||||
- **Execution log streaming** — MCP has no push model. `get_execution_status`
|
||||
returns the latest output; full streaming stays WebUI-only.
|
||||
|
||||
---
|
||||
|
||||
## 3. Unified sequence (5 stages, 31 tasks)
|
||||
|
||||
### Stage 1: Foundation — shared infrastructure (3 tasks)
|
||||
|
||||
These unlock everything downstream. Do first.
|
||||
|
||||
**T1 — Target validation in `run`** *(audit Task 2)*
|
||||
`internal/mcp/server.go`: before dispatching a `run` command, validate prefix
|
||||
against target type. `qm`/`pct`/`pvesh` → `host:*` only. `systemctl`/`docker` →
|
||||
`host:*` or `lxc:*`. Mismatch returns error without execution.
|
||||
|
||||
**T2 — Async `run` + 120s timeout** *(audit Task 3)*
|
||||
`cmd/nomos`: raise MCP client timeout 30s → 120s.
|
||||
`internal/mcp/server.go`: when classifier detects `sleep`/`wait`/poll loops in
|
||||
the command, start execution and return `execution_id` immediately. Agent polls
|
||||
with `get_execution_status`. Execution continues server-side even if client
|
||||
timeout.
|
||||
|
||||
**T3 — Execution linkage** *(audit Task 9)*
|
||||
`internal/mcp/server.go`: when `run` creates an execution, write into
|
||||
`nomos_plan_executions` (session_id + plan_step_seq + execution_id). Pass
|
||||
`session_id` from MCP request headers into `audit_log` writes.
|
||||
|
||||
---
|
||||
|
||||
### Stage 2: External agent observe (8 tasks)
|
||||
|
||||
All read-only MCP tools. Zero risk, ship fast. Unblocks external agents from
|
||||
understanding system state.
|
||||
|
||||
**T4 — `get_dashboard_summary`**
|
||||
Fleet health counts, signals by severity, pending approvals, event rate. One
|
||||
call instead of 4. Wraps existing `GetDashboardSummary` DB query.
|
||||
|
||||
**T5 — `get_ontology`**
|
||||
Entity types, relationship types, lifecycle states, monitoring specs. Agents
|
||||
need this to reason about the schema.
|
||||
|
||||
**T6 — `list_checks`**
|
||||
Per-entity health checks with verdict, last run, probe output. Filter by entity
|
||||
slug or check state.
|
||||
|
||||
**T7 — `list_executions`**
|
||||
Cursor-paginated execution history. Filter by entity slug, status, risk class.
|
||||
Depends on T3 (execution linkage) for session/entity filtering.
|
||||
|
||||
**T8 — `get_knowledge_revisions`**
|
||||
Version history for a knowledge entity. Agent can see what changed and when.
|
||||
|
||||
**T9 — `get_knowledge_duplicates`**
|
||||
Near-duplicate knowledge entries via trigram clustering. Wraps existing
|
||||
`knowledgeDuplicates` query.
|
||||
|
||||
**T10 — `get_knowledge_orphans`**
|
||||
Knowledge entries not linked to any entity. Agent can suggest cleanup.
|
||||
|
||||
**T11 — `list_knowledge_tags`**
|
||||
All tags with counts. Agent can see the taxonomy.
|
||||
|
||||
**T12 — `list_entity_sessions`** *(Phase 3 polish)*
|
||||
Active Nomos sessions linked to an entity. Depends on T3 (execution linkage).
|
||||
|
||||
**T13 — `find_entities_by`** *(Phase 3 polish)*
|
||||
Search entities by attribute (IP, port, version, tag). More flexible than
|
||||
`list_entities` (type/state only).
|
||||
|
||||
**T14 — MCP resources**
|
||||
Expose entities, knowledge entries, and executions as MCP resource templates:
|
||||
`oikos://entity/{slug}`, `oikos://knowledge/{id}`, `oikos://execution/{id}`.
|
||||
MCP clients that support resources (Claude Desktop, Goose) can browse and attach
|
||||
them to conversations.
|
||||
|
||||
---
|
||||
|
||||
### Stage 3: Nomos reliability (6 tasks)
|
||||
|
||||
Fixes the worst failure modes found in the 5-session audit.
|
||||
|
||||
**T15 — Prevent premature `complete_task(success)`** *(audit Task 1)*
|
||||
`internal/mcp/tools.go`: when `complete_task` with `outcome=success`, verify the
|
||||
summary doesn't contradict known state. If goal mentions a URL but the last
|
||||
probe shows non-200, log a warning.
|
||||
`nomos/SOUL.md`: explicit rule — restate goal, verify every condition before
|
||||
calling success. If any condition is "probably works," use `outcome=partial`.
|
||||
|
||||
**T16 — Plan step integrity: require `replaced_reason`** *(audit Task 4)*
|
||||
Schema: add `replaced_reason TEXT` to `session_plan_steps`.
|
||||
`cmd/nomos`: when agent emits `update_plan_step(status=replaced)`, require
|
||||
non-empty reason (enum: `wrong_diagnosis`, `scope_change`, `blocked`,
|
||||
`superseded`, `operator_override`).
|
||||
`nomos/SOUL.md`: explicit rule — complete or skip steps. Replacing all steps
|
||||
with no reason is a session-quality violation.
|
||||
|
||||
**T17 — Force `propose_plan` on session resume** *(audit Task 5)*
|
||||
`cmd/nomos`: when a session with status `done`/`failed` receives a new user
|
||||
message, reset plan state. Require fresh `propose_plan` before any `run`.
|
||||
The "must have plan" guard treats resumed sessions as plan-less.
|
||||
|
||||
**T18 — Scope gate: surface `session_questions` on context switch** *(audit Task 6)*
|
||||
`cmd/nomos` system prompt: "When investigation leads to a subsystem unrelated to
|
||||
the expressed goal, call `session_questions` before taking action."
|
||||
`nomos/SOUL.md`: explicit rule — ask before pivoting.
|
||||
|
||||
**T19 — Command syntax validation in `run`** *(audit Task 13)*
|
||||
`internal/mcp/server.go`: before executing, reject literal `\n` in commands,
|
||||
backslash-continuation on last line, `head - n`/`grep - i` space-before-flag
|
||||
typos, and `&& \n` patterns from LLM formatting errors.
|
||||
|
||||
**T20 — Stuck-session reaping** *(audit Task 14, from 2026-08-03 plan)*
|
||||
Reap sessions with `closed_at IS NULL` and no message in 30 minutes. Set
|
||||
status=failed, outcome=failure.
|
||||
|
||||
---
|
||||
|
||||
### Stage 4: External agent act (9 tasks)
|
||||
|
||||
Mutation MCP tools. Each writes audit log + emits event. Requires Stage 1
|
||||
infrastructure (T1 target validation, T2 async run, T3 execution linkage).
|
||||
Follows existing direct-DB patterns — no HTTP API calls.
|
||||
|
||||
**T21 — `ack_signal(signal_id)`**
|
||||
Acknowledge an open signal. Agent investigating an alert marks it acknowledged.
|
||||
|
||||
**T22 — `resolve_signal(signal_id, resolution?)`**
|
||||
Resolve a signal with optional resolution note.
|
||||
|
||||
**T23 — `mute_signal(signal_id, duration?)`**
|
||||
Temporarily mute a signal. Optional duration (default 1h).
|
||||
|
||||
**T24 — `cancel_execution(execution_id, reason)`**
|
||||
Cancel a queued/running execution. Depends on T2 (async `run` returns
|
||||
`execution_id`) and T3 (execution linkage for audit context).
|
||||
|
||||
**T25 — `update_check(check_id, enabled)`**
|
||||
Enable/disable a health check. Agent suppresses a noisy probe.
|
||||
|
||||
**T26 — `delete_knowledge(knowledge_id)`**
|
||||
Soft-delete a knowledge entry (move to trash, restorable).
|
||||
|
||||
**T27 — `restore_knowledge(knowledge_id)`**
|
||||
Restore a trashed knowledge entry.
|
||||
|
||||
**T28 — `merge_knowledge(source_id, target_id)`**
|
||||
Fold one knowledge entry into another. Source gets soft-deleted, content
|
||||
appended to target.
|
||||
|
||||
**T29 — `rename_knowledge_tag(old_name, new_name)`**
|
||||
Bulk-rename a tag across all knowledge entries.
|
||||
|
||||
---
|
||||
|
||||
### Stage 5: Close the learning loop (5 tasks)
|
||||
|
||||
Turn execution data into persistent knowledge. Currently all learning tables are
|
||||
empty (0 classifications, 0 feedback, 0 patterns, 0 skills).
|
||||
|
||||
**T30 — Auto-classify every `run` → `classifications` table** *(audit Task 11)*
|
||||
`internal/mcp/server.go`: `run` already calls `classifyCommand`. Write the
|
||||
result to the `classifications` table (risk_class + route + patterns matched).
|
||||
Currently 0 rows despite 1,884 executions.
|
||||
|
||||
**T31 — Auto-upsert knowledge on session close** *(audit Task 7)*
|
||||
`cmd/nomos`: on `complete_task` (any outcome), auto-generate a knowledge entry:
|
||||
title=`<date>: <goal>`, content with Outcome/Root cause/What was done/Unresolved
|
||||
sections, tags=`[session:<id>]`, linked to involved entities.
|
||||
|
||||
**T32 — Auto-feedback on session close** *(audit Task 12)*
|
||||
`cmd/nomos`: on `complete_task`, generate a `feedback` entry: session_id,
|
||||
outcome, observation, lesson, side_effects. Daily cron job reads recent feedback
|
||||
and extracts patterns (recurring root causes, same-fix-applied-multiple-times).
|
||||
|
||||
**T33 — Token tracking** *(audit Task 8)*
|
||||
`cmd/nomos`: after each LLM call, extract `usage.total_tokens` from the response
|
||||
and write to `agent_activity.token_count`. Currently NULL for all rows.
|
||||
|
||||
**T34 — Plan quality metric** *(audit Task 10)*
|
||||
`cmd/nomos`: at session close, compute `completed_steps / total_steps` (currently
|
||||
~38%). Write as session attribute. Track over time to measure impact of T16+T17.
|
||||
|
||||
---
|
||||
|
||||
## 4. Validation
|
||||
|
||||
| Task | Test |
|
||||
|---|---|
|
||||
| T1 | `run("lxc:dns", "qm stop 100")` → error: "qm is a Proxmox host command" |
|
||||
| T2 | `run` with `sleep 45; echo done` → returns `execution_id` immediately; `get_execution_status` eventually shows completed |
|
||||
| T3 | After `run`, `nomos_plan_executions` has row linking session + step + execution |
|
||||
| T4 | `get_dashboard_summary()` returns health counts, signal counts, approval count in one call |
|
||||
| T5 | `get_ontology()` returns entity_types, relationship_types, lifecycle_states |
|
||||
| T6 | `list_checks(entity_slug="lxc:jellyfin")` returns all checks with verdict + last run |
|
||||
| T7 | `list_executions(entity_slug="host:hubris", limit=10)` returns cursor-paginated list |
|
||||
| T8 | `get_knowledge_revisions(id)` returns ordered revision list with timestamps |
|
||||
| T9 | `get_knowledge_duplicates()` returns clusters with similarity scores |
|
||||
| T10 | `get_knowledge_orphans()` returns knowledge entries with zero entity links |
|
||||
| T11 | `list_knowledge_tags()` returns {name, count} for all tags |
|
||||
| T12 | `list_entity_sessions("lxc:jellyfin")` returns active sessions with goal + status |
|
||||
| T13 | `find_entities_by(ip="10.0.0.5")` returns matching entities |
|
||||
| T14 | MCP client can browse `oikos://entity/*` resources |
|
||||
| T15 | Session with goal "make X reachable" where last ping shows 502 → `complete_task(success)` warns or rejects |
|
||||
| T16 | `update_plan_step(status=replaced)` with no reason → rejected |
|
||||
| T17 | Resumed session calls `run` before `propose_plan` → blocked |
|
||||
| T18 | Agent pivots to unrelated subsystem → `session_questions` is called |
|
||||
| T19 | `run` with `head - n /etc/hosts` → rejected with syntax error |
|
||||
| T20 | Session idle for 30+ min with no `closed_at` → reaped (status=failed) |
|
||||
| T21 | `ack_signal(id)` → signal status transitions to acknowledged, audit logged |
|
||||
| T22 | `resolve_signal(id, "fixed DNS")` → resolved with note |
|
||||
| T23 | `mute_signal(id, 3600)` → muted for 1 hour, auto-unmutes |
|
||||
| T24 | `cancel_execution(id, "wrong target")` → execution cancelled, audit logged |
|
||||
| T25 | `update_check(id, false)` → check disabled, scheduler stops probing |
|
||||
| T26 | `delete_knowledge(id)` → soft-deleted (trashed), restorable |
|
||||
| T27 | `restore_knowledge(id)` → restored from trash, reappears in list |
|
||||
| T28 | `merge_knowledge(src, dst)` → src deleted, content appended to dst |
|
||||
| T29 | `rename_knowledge_tag("old", "new")` → all entries updated |
|
||||
| T30 | After any `run`, `classifications` has row with risk_class + route |
|
||||
| T31 | `complete_task` → knowledge entry created automatically with session link |
|
||||
| T32 | `complete_task` → feedback entry created; daily job extracts pattern if same root cause appears ≥3 times |
|
||||
| T33 | `agent_activity.token_count` is non-NULL after LLM call |
|
||||
| T34 | Session close writes `plan_adherence` attribute (% steps completed) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Files touched
|
||||
|
||||
| File | Tasks |
|
||||
|---|---|
|
||||
| `internal/mcp/server.go` | T1, T2, T3, T19, T24, T30 |
|
||||
| `internal/mcp/tools.go` | T4–T14, T21–T29 |
|
||||
| `internal/mcp/discover.go` | (no changes — references for T10/T11 patterns) |
|
||||
| `cmd/nomos/main.go` (or config) | T2 (timeout), T16, T17, T18, T31, T32, T33, T34 |
|
||||
| `nomos/SOUL.md` | T15, T16, T18 |
|
||||
| `internal/httpapi/` | T3 (audit_log.session_id plumbing) |
|
||||
| DB migrations | T16 (replaced_reason column) |
|
||||
|
||||
---
|
||||
|
||||
## 6. What stays WebUI-only
|
||||
|
||||
| Feature | Reason |
|
||||
|---|---|
|
||||
| FleetMap visual graph | Canvas rendering — not an MCP concern |
|
||||
| uPlot metric charts | Raw data available via `query_metrics`/`get_trend` |
|
||||
| Desktop shell, Cluck, App Store | Pure UI layer |
|
||||
| SSE event streaming | MCP has no push model; polling covers it |
|
||||
| Execution log streaming | `get_execution_status` returns latest output |
|
||||
| Nomos session chat | MCP is a tool interface, not a chat agent |
|
||||
| Knowledge wiki editor (revision browse, cleanup UI) | MCP tools expose the data + mutations; UI provides the editing experience |
|
||||
| Approval queue with Approve/Deny buttons | Approvals stay operator-gated via WebUI/Matrix |
|
||||
| Client enrollment flow | Enrollment is IP-gated, not an MCP tool |
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
- **`approve_execution` / `deny_execution` MCP tools** — dropped. The fix is raising
|
||||
`auto_act` for `reversible_low` (policy change, no code).
|
||||
- **`delete_entity` MCP tool** — separate lifecycle management concern.
|
||||
- **Per-client bearer tokens** — open item tracked in CLIENTS.md. Until they
|
||||
exist, external agents share the same `OIKOS_MCP_BEARER_TOKEN`.
|
||||
- **Exactly-once pattern extraction from feedback** — T32 seeds the pipeline;
|
||||
the full pattern-mining algorithm (TF-IDF clustering, causal inference from
|
||||
event timelines) is future work.
|
||||
@@ -0,0 +1,158 @@
|
||||
# 2026-08-05 — Agent execution safety: QEMU guest agent guardrails + host-mutation gate
|
||||
|
||||
**Status:** Plan.
|
||||
**Context:** ZimaOS NFS recovery session (2026-08-04/05) surfaced three systemic
|
||||
failures in how agents drive oikos mutations. The `run` tool queued an execution
|
||||
against a VM whose QEMU guest agent was down — it sat `pending_approval` forever,
|
||||
never executed, and the agent silently fell back to raw SSH. That same raw-SSH
|
||||
fallback was then used to `apt-get install nfs-kernel-server` directly on the
|
||||
hubris **PVE host**, crashing it and taking the whole homelab subnet down for
|
||||
~15 minutes.
|
||||
|
||||
**Trigger:** Incident `investigation:nomos/incident-hubris-crash-from-nfs-kernel-server-on-pve-host-2026-08-05`
|
||||
(2026-08-05) + session audit. The crash was caused by an agent bypassing the
|
||||
`run` approval gate, which exists precisely to catch that kind of mistake.
|
||||
|
||||
---
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
The OODA loop's Act phase is the security boundary (ADR 0012: "Hermes has no
|
||||
direct SSH access... all mutations go through the execution queue"). This session
|
||||
proved the boundary has three leaks:
|
||||
|
||||
1. **`run` on a VM with a dead QEMU guest agent queues silently.** The execution
|
||||
is classified `config_mutation`, queued for approval, and *never fails* — it
|
||||
just sits in `pending_approval` while the agent assumes progress. There is no
|
||||
feedback that the underlying transport (`qm guest exec`) cannot work.
|
||||
|
||||
2. **No guardrail against host-level package/kernel mutations.** `apt-get install`
|
||||
targeting a `proxmox-host` entity is classified `config_mutation` and gated —
|
||||
*if* the agent routes it through `run`. When the first `run` call stalls
|
||||
(leak #1), the agent falls back to raw SSH, which has no classification at all.
|
||||
The crash was the direct result of that fallback.
|
||||
|
||||
3. **Agents are trusted to self-report the `health` attribute.** `update_entity_attributes`
|
||||
let the agent set `health:"healthy"` on `lxc:nfs-export`, which derived 4
|
||||
spurious health checks. Health is scheduler-owned; agents shouldn't write it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Changes
|
||||
|
||||
### I — `run` pre-flights the execution transport before queueing
|
||||
|
||||
**Why:** A queued execution that can never run is worse than a failed one — it
|
||||
looks like progress, stalls the agent, and (this session) pushed the agent into
|
||||
the unsafe raw-SSH fallback.
|
||||
|
||||
**What:**
|
||||
|
||||
In the `run` tool handler (`internal/mcp/`), before inserting the execution row:
|
||||
|
||||
- If target type is `vm`, read the target entity's attributes. If
|
||||
`qemu_guest_agent` is missing or `not_running`, return an immediate error:
|
||||
`"run on vm:zimaos blocked: QEMU guest agent is not running (qm guest exec
|
||||
unavailable). Start the agent first or use a different target."`
|
||||
- Same check for `lxc` targets whose `pct exec` path is known-broken (optional —
|
||||
start with VM only).
|
||||
|
||||
This converts "queued forever" into a fast, actionable failure the agent can
|
||||
recover from immediately.
|
||||
|
||||
**Risk class:** read-only (validation only, no execution row created).
|
||||
|
||||
**Test:** Unit test with a fake VM entity that has `qemu_guest_agent: not_running`
|
||||
→ assert the tool returns the blocking error and inserts no execution row.
|
||||
|
||||
### II — Host-mutation command guardrail in `run` classification
|
||||
|
||||
**Why:** `apt-get install` on a Proxmox host is the exact class of mutation that
|
||||
must always hit the approval gate. The classifier already escalates `apt`/kernel
|
||||
touches; this makes the escalation explicit and documented so agents stop
|
||||
second-guessing it.
|
||||
|
||||
**What:**
|
||||
|
||||
- In `seeds/policy.yaml`, add an explicit rule: `proxmox-host` targets + commands
|
||||
matching `(apt-get install|apt install|dpkg|modprobe|kernel)` ⇒ `config_mutation`
|
||||
(operator approval required), never `reversible_low`.
|
||||
- Extend the classifier to also flag `update-rc.d`, `systemctl enable` on host
|
||||
targets if not already covered.
|
||||
- Add a note in the `run` tool description: "Host-level package/kernel mutations
|
||||
always require operator approval."
|
||||
|
||||
**Risk class:** policy change — knowledge/DB, deploy via seed ingest.
|
||||
|
||||
**Test:** `classify_command("apt-get install -y nfs-kernel-server", declared_risk=read_only)`
|
||||
on `host:hubris` → must return `config_mutation`, not read_only. Add a fixture test
|
||||
in the classifier suite.
|
||||
|
||||
### III — `health` attribute is read-only for agents
|
||||
|
||||
**Why:** This session's `update_entity_attributes({"health":"healthy"})` on
|
||||
`lxc:nfs-export` derived 4 checks. Health is computed by the scheduler from probe
|
||||
results; an agent asserting it creates false monitoring.
|
||||
|
||||
**What:**
|
||||
|
||||
- In `update_entity_attributes` handler: reject (or strip with a warning) the
|
||||
`health` key. Return a message: `"health is scheduler-owned; attribute ignored.
|
||||
Use get_health_summary/list_checks to observe it."`
|
||||
- Document in the tool description: `"Do not write health — it is derived from
|
||||
probes."`
|
||||
|
||||
**Risk class:** knowledge-graph mutation (existing), no infra impact.
|
||||
|
||||
**Test:** `update_entity_attributes(slug=lxc:nfs-export, attributes={"health":"healthy"})`
|
||||
→ response shows health ignored, other keys merged.
|
||||
|
||||
### IV — Agent-side: record discovered dependency edges
|
||||
|
||||
**Why:** The session discovered `vm:zimaos` depends on `lxc:nfs-export` for
|
||||
`/media/library`, but no `depends-on` edge was recorded. A future agent
|
||||
investigating a ZimaOS mount failure would have no graph signal pointing at the
|
||||
NFS server.
|
||||
|
||||
**What (agent behaviour, not code):** After confirming a runtime dependency, call
|
||||
`create_relationship(source, target, type)` immediately. Concretely this session:
|
||||
`create_relationship("vm:zimaos", "lxc:nfs-export", "depends-on")`.
|
||||
|
||||
**Where to enforce:** Update the homelab-context `HERMES.md` / SOUL.md agent
|
||||
instructions with a one-line rule: "When you discover a dependency between two
|
||||
entities (a service consumes another's export/mount/API), record it with
|
||||
`create_relationship` in the same session." Plus a runbook in
|
||||
`.agents/skills/` if one doesn't exist.
|
||||
|
||||
**Risk class:** knowledge-graph mutation, auto-approves.
|
||||
|
||||
**Test:** Manual — after recording the edge, `get_relations("vm:zimaos")` shows
|
||||
`depends-on → lxc:nfs-export`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Rollout
|
||||
|
||||
| Step | Item | When |
|
||||
| ---- | ---- | ---- |
|
||||
| 1 | II — policy.yaml classifier rule + tests | next seed ingest |
|
||||
| 2 | I — `run` VM transport pre-flight + test | next mcp server deploy |
|
||||
| 3 | III — health read-only guard + test | same deploy as I |
|
||||
| 4 | IV — agent instruction update in homelab-context | commit + sync |
|
||||
| 5 | Verify: re-run `classify_command` + manual `run` on vm:zimaos (agent now up) | after deploy |
|
||||
|
||||
---
|
||||
|
||||
## 4. Out of scope
|
||||
|
||||
- Per-client MCP bearer tokens (separate track, ADR 0012 note).
|
||||
- `request_execution` re-introduction — the unified `run` primitive stays.
|
||||
- Automating the Technitium DHCP reservation UI (this session's leftover — the
|
||||
reservation for `BC:24:11:22:C2:F2 → 192.168.8.102` was added manually in the
|
||||
web UI; consider a `runbook:technitium-dhcp-reservation` doc in a follow-up).
|
||||
|
||||
---
|
||||
|
||||
## 5. Changelog
|
||||
|
||||
- 2026-08-05 — plan created from ZimaOS/NFS session audit + hubris crash incident.
|
||||
@@ -17,12 +17,10 @@ went sideways, open an investigation.
|
||||
| 2026-07-14 | [Activity gaps](2026-07-14-activity-gaps.md) | In Progress |
|
||||
| 2026-07-14 | [Activity timeline](2026-07-14-activity-timeline.md) | In Progress |
|
||||
| 2026-07-17 | [Codebase review, lint audit, and documentation maintenance](2026-07-17-codebase-review-and-cleanup.md) | Report delivered — doc/tooling fixes applied; code refactors pending |
|
||||
| 2026-07-18 | [Session review: three recent sessions](2026-07-18-session-review-three-sessions.md) | Implemented in v0.7.12 — P0.1/P0.2/P1.3/P1.4/P1.5/P1.6/P1.8/P2.10; P1.7 and P2.9 deferred (retry cap covers) |
|
||||
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Implemented in v0.8.0 — see deviation note; physics/window-interaction follow-ups tracked separately |
|
||||
| 2026-07-20 | [Session review: past 10 sessions](2026-07-20-session-review-ten-sessions.md) | Implemented in v0.7.13 — all P0/P1/P2 items landed |
|
||||
| 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0–P2 implemented; P3 ("cool stuff") ideas open |
|
||||
| 2026-07-21 | [Frontend as OS + Apps — architecture audit & refactor](2026-07-21-frontend-os-apps-architecture.md) | Planned — Phase 1 ready |
|
||||
| 2026-08-03 | [Nomos chat: reliability & predictability audit](2026-08-03-nomos-chat-reliability-and-ux-audit.md) | In Progress — F1–F7 shipped in v0.15.0; F8 + follow-ups open |
|
||||
| 2026-08-04 | [Hermes MCP client integration](done/2026-08-04-hermes-mcp-client-integration.md) | Done — deployed |
|
||||
| 2026-08-05 | [Agent execution safety: QEMU guest agent gate + host-mutation guard](done/2026-08-05-agent-execution-safety-qemu-guest-agent-gate.md) | Done — implemented (1b9c761) |
|
||||
|
||||
## Done
|
||||
|
||||
@@ -60,10 +58,21 @@ See [`done/`](done/) for executed plans:
|
||||
| 2026-07-14 | [Post-fix session remainders: empty responses & plan drift](done/2026-07-14-post-fix-session-remainders.md) |
|
||||
| 2026-07-15 | [Plan-first and iteration](done/2026-07-15-plan-first-and-iteration.md) |
|
||||
| 2026-07-15 | [WhatsApp session audit](done/2026-07-15-whatsapp-session-audit.md) |
|
||||
| 2026-07-18 | [Session review: three recent sessions](done/2026-07-18-session-review-three-sessions.md) |
|
||||
| 2026-07-20 | [Desktop mascot ("Cluck")](done/2026-07-20-desktop-mascot.md) |
|
||||
| 2026-07-20 | [Session review: past 10 sessions](done/2026-07-20-session-review-ten-sessions.md) |
|
||||
| 2026-07-21 | [Chat window full polish](done/2026-07-21-chat-full-polish.md) |
|
||||
| 2026-07-29 | [Make health reflect reality + complete the knowledge graph](done/2026-07-29-health-check-reality-and-knowledge-graph.md) |
|
||||
| 2026-07-30 | [Session review: plan drift & dead activity panel](done/2026-07-30-session-review-plan-drift-and-dead-activity-panel.md) |
|
||||
| 2026-08-03 | [Nomos chat changes review (P0/P1/P2)](done/2026-08-03-nomos-chat-changes-review.md) |
|
||||
| 2026-08-03 | [Nomos chat: reliability & predictability audit](done/2026-08-03-nomos-chat-reliability-and-ux-audit.md) |
|
||||
| 2026-08-03 | [Adopt cyberspace.online terminal aesthetic + dithered images](done/2026-08-03-cyberspace-style-adoption.md) |
|
||||
| 2026-08-03 | [Nomos chat: working-visibility, message queue, generation-aware timeline](done/2026-08-03-nomos-chat-working-visibility.md) |
|
||||
| 2026-08-04 | [Chat interaction overhaul: inline progressive stream + thinking blocks](done/2026-08-04-chat-window-overhaul.md) |
|
||||
|
||||
## Conventions
|
||||
|
||||
- File name: `YYYY-MM-DD-<slug>.md`. Use the *target* date if known, otherwise the planning date.
|
||||
- Status: `Planned` → `In Progress` → `Done` (move to `done/` on completion).
|
||||
- When done: add a changelog entry on every affected node page, then move the file to `done/`.
|
||||
- Plans are append-only once execution starts — don't rewrite pre-flight intent after the fact.
|
||||
- Plans are append-only once execution starts — don't rewrite pre-flight intent after the fact.
|
||||
79
scripts/fix-zimaos-nfs.sh
Normal file
79
scripts/fix-zimaos-nfs.sh
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# Fix NFS mounts on ZimaOS — both library shares
|
||||
# Run: bash /tmp/fix-zimaos-nfs.sh
|
||||
# You'll be prompted for sudo password once
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Step 1: Grant passwordless sudo for mount/umount ==="
|
||||
echo "dtoro ALL=(ALL) NOPASSWD: /usr/sbin/mount.nfs, /usr/sbin/umount.nfs, /bin/mount, /bin/umount, /usr/bin/mount" | sudo tee /etc/sudoers.d/zimaos-nfs > /dev/null
|
||||
sudo chmod 440 /etc/sudoers.d/zimaos-nfs
|
||||
echo " ✓ sudoers drop-in created"
|
||||
|
||||
echo "=== Step 2: Mount library from hubris (nfs-export LXC) ==="
|
||||
sudo mkdir -p /media/library
|
||||
sudo mount -t nfs -o nfsvers=4,rw,hard,intr 192.168.8.200:/mnt/library /media/library
|
||||
echo " ✓ /media/library ← 192.168.8.200:/mnt/library"
|
||||
|
||||
echo "=== Step 3: Mount ludo-library from strong ==="
|
||||
sudo mkdir -p /media/ludo-library
|
||||
sudo mount -t nfs -o nfsvers=4,rw,hard,intr 192.168.8.241:/mnt/media_local /media/ludo-library
|
||||
echo " ✓ /media/ludo-library ← 192.168.8.241:/mnt/media_local"
|
||||
|
||||
echo "=== Step 4: Verify ==="
|
||||
echo ""
|
||||
df -h | grep -E 'nfs|192.168'
|
||||
echo ""
|
||||
echo "--- /media/library contents ---"
|
||||
ls /media/library/ | head -10
|
||||
echo ""
|
||||
echo "--- /media/ludo-library contents ---"
|
||||
ls /media/ludo-library/ | head -10
|
||||
|
||||
echo ""
|
||||
echo "=== Step 5: Persistent systemd mount units ==="
|
||||
|
||||
# Library mount unit
|
||||
sudo tee /etc/systemd/system/media-library.mount > /dev/null << 'MOUNTUNIT'
|
||||
[Unit]
|
||||
Description=Mount nfs-export:/mnt/library as library
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Mount]
|
||||
What=192.168.8.200:/mnt/library
|
||||
Where=/media/library
|
||||
Type=nfs
|
||||
Options=nfsvers=4,rw,hard,intr
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
MOUNTUNIT
|
||||
|
||||
# Ludo-library mount unit
|
||||
sudo tee /etc/systemd/system/media-ludo\x2dlibrary.mount > /dev/null << 'MOUNTUNIT2'
|
||||
[Unit]
|
||||
Description=Mount strong:/mnt/media_local as ludo-library
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Mount]
|
||||
What=192.168.8.241:/mnt/media_local
|
||||
Where=/media/ludo-library
|
||||
Type=nfs
|
||||
Options=nfsvers=4,rw,hard,intr
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
MOUNTUNIT2
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable media-library.mount
|
||||
sudo systemctl enable media-ludo\x2dlibrary.mount
|
||||
echo " ✓ Both systemd mount units created and enabled"
|
||||
|
||||
echo ""
|
||||
echo "=== DONE ==="
|
||||
echo "Both NFS mounts active and persistent across reboots:"
|
||||
echo " /media/library ← 192.168.8.200:/mnt/library (hubris nfs-export LXC)"
|
||||
echo " /media/ludo-library ← 192.168.8.241:/mnt/media_local (strong)"
|
||||
@@ -87,10 +87,12 @@ entities:
|
||||
mesh: {netbird: {ip: 100.122.165.149, fqdn: netbird-ionos.netbird.selfhosted}}
|
||||
ssh: {user: root}
|
||||
note: netbird mgmt+signal+relay+dashboard + coturn; sshd locked to hubris pubkey
|
||||
monitoring: none # host unreachable from the lab (no ICMP, port 22
|
||||
# times out even via hubris); liveness is covered
|
||||
# by its services — authentik/matrix http checks
|
||||
# and the matrix cert-expiry dial it on :443
|
||||
monitoring: [http] # public HTTPS probe via https://mcp.hubris.network
|
||||
# (2026-08-05: was `none` — the VPS went silent for
|
||||
# 7 days because nothing probed it. The standalone-server
|
||||
# type inherits [ping,resource,updates] from machine, but
|
||||
# SSH/ICMP don't reach it from the lab; an HTTP probe on
|
||||
# the public endpoint is the reachable liveness signal).
|
||||
- slug: "ws:mac-mini"
|
||||
type: workstation
|
||||
name: mac-mini
|
||||
|
||||
@@ -241,6 +241,11 @@ entity_types:
|
||||
layer: infrastructure
|
||||
lifecycle: infrastructure
|
||||
description: Machine outside PVE management (e.g. external VPS).
|
||||
# Override inherited [ping, resource, updates] — standalone servers may
|
||||
# not be SSH/ICMP-reachable from the scheduler. HTTP is the least-
|
||||
# common-denominator liveness signal. Entities with full SSH access
|
||||
# can override to [ping, resource, http].
|
||||
monitoring: [http]
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
@@ -381,11 +386,13 @@ entity_types:
|
||||
layer: infrastructure
|
||||
lifecycle: infrastructure
|
||||
description: DNS zone (e.g. split-horizon hubris.network).
|
||||
monitoring: none # no `dns` checker exists yet; declaring [dns]
|
||||
# made every zone an unresolvable `unmonitored`
|
||||
# signal. Flip back to [dns] when a checker lands.
|
||||
# Requires ontology re-ingest to take effect;
|
||||
# coverageSweep then auto-clears the stale signals.
|
||||
monitoring: [dns] # resolves the zone's apex via the configured
|
||||
# resolver, verifying the zone is authoritatively
|
||||
# reachable. (2026-08-05: was `none` — the DNS layer
|
||||
# had zero checks, so a stale record like
|
||||
# matrix→82.165.190.79 went unnoticed. Requires
|
||||
# ontology re-ingest; coverageSweep clears stale
|
||||
# signals after.)
|
||||
attributes: {type: object, properties: {zone: {type: string}, authority: {type: string}}}
|
||||
dns-record:
|
||||
parent: entity
|
||||
|
||||
@@ -70,6 +70,11 @@ approval_rules:
|
||||
- {entity_type: docker-container, action: restart, risk_class: reversible_low, autonomy_level: auto}
|
||||
- {entity_type: machine, action: apt-upgrade, risk_class: config_mutation, autonomy_level: escalate}
|
||||
- {entity_type: machine, action: reboot, risk_class: config_mutation, autonomy_level: escalate}
|
||||
# host-level package/kernel install (apt-get install, dpkg, modprobe, systemctl enable)
|
||||
# always classifies as config_mutation — the classifier defaults to config_mutation
|
||||
# for any command not in the read-only allowlist, so apt-get install reaches this
|
||||
# tier naturally. Documented explicitly here so agents stop second-guessing:
|
||||
# host mutations always need operator approval.
|
||||
- {entity_type: machine, action: format-disk, risk_class: destructive, autonomy_level: never}
|
||||
- {entity_type: config-repo, action: edit, risk_class: config_mutation, autonomy_level: escalate}
|
||||
- {entity_type: deploy-pipeline, action: trigger, risk_class: config_mutation, autonomy_level: escalate}
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Oikos</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Inknut+Antiqua:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
||||
|
||||
653
web/package-lock.json
generated
653
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,7 @@
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joan/procedural-glyph-engine": "file:../vendor",
|
||||
"@surdeddd/wmkit": "^0.3.0",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
|
||||
BIN
web/public/fonts/JetBrainsMono-Bold.woff2
Normal file
BIN
web/public/fonts/JetBrainsMono-Bold.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
web/public/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/VT323-Regular.woff2
Normal file
BIN
web/public/fonts/VT323-Regular.woff2
Normal file
Binary file not shown.
374
web/src/app.css
374
web/src/app.css
@@ -2,6 +2,29 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── Self-hosted type (cyberspace terminal aesthetic) ── */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/JetBrainsMono-Bold.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'VT323';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/VT323-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* bits-ui components (Slider, and any future orientation/disabled-aware
|
||||
primitive) style themselves via shorthand data-* variants that Tailwind
|
||||
v4 doesn't ship — it only auto-generates variants for bare boolean data
|
||||
@@ -14,13 +37,16 @@
|
||||
@custom-variant data-disabled (&[data-disabled]);
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||
--font-mono: 'DM Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--font-heading: 'Inknut Antiqua', Georgia, serif;
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--font-sans: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--font-heading: 'JetBrains Mono', ui-monospace, Menlo, monospace;
|
||||
/* Square corners across the whole radius scale (--radius is pinned to 0
|
||||
by both themes below). Kept as a 4-step scale so any future softer theme
|
||||
can relax just --radius and get graded corners back for free. */
|
||||
--radius-sm: var(--radius);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-xl: var(--radius);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
@@ -57,103 +83,107 @@
|
||||
--color-chart-5: var(--chart-5);
|
||||
}
|
||||
|
||||
/* ── Terracotta Light Theme ── */
|
||||
/* ── Cyberspace Light (black ink on warm cream paper) ──
|
||||
Ported from cyberspace.online's 3-color model (fg/bg/fgDim). Light and
|
||||
dark are exact inverses of the same cream (#efe5c0). Emphasis is by
|
||||
inversion (primary = fg ink), borders are fg-derived hairlines, and the
|
||||
radius is 0 so every surface is square. */
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.94 0.02 55);
|
||||
--foreground: oklch(0.18 0.03 45);
|
||||
--card: oklch(0.91 0.025 55);
|
||||
--card-foreground: oklch(0.18 0.03 45);
|
||||
--popover: oklch(0.91 0.025 55);
|
||||
--popover-foreground: oklch(0.18 0.03 45);
|
||||
--primary: oklch(0.55 0.14 45);
|
||||
--primary-foreground: oklch(0.95 0.02 55);
|
||||
--secondary: oklch(0.86 0.03 55);
|
||||
--secondary-foreground: oklch(0.18 0.03 45);
|
||||
--muted: oklch(0.86 0.025 55);
|
||||
--muted-foreground: oklch(0.45 0.04 45);
|
||||
--accent: oklch(0.84 0.035 55);
|
||||
--accent-foreground: oklch(0.18 0.03 45);
|
||||
--destructive: oklch(0.5 0.2 25);
|
||||
--destructive-foreground: oklch(0.95 0.02 55);
|
||||
--border: oklch(0.6 0.08 45);
|
||||
--input: oklch(0.78 0.04 55);
|
||||
--ring: oklch(0.55 0.14 45);
|
||||
--chart-1: oklch(0.55 0.14 45);
|
||||
--chart-2: oklch(0.65 0.1 70);
|
||||
--chart-3: oklch(0.5 0.08 30);
|
||||
--chart-4: oklch(0.6 0.06 90);
|
||||
--chart-5: oklch(0.4 0.04 45);
|
||||
--sidebar: oklch(0.9 0.025 55);
|
||||
--sidebar-foreground: oklch(0.18 0.03 45);
|
||||
--sidebar-primary: oklch(0.55 0.14 45);
|
||||
--sidebar-primary-foreground: oklch(0.95 0.02 55);
|
||||
--sidebar-accent: oklch(0.84 0.035 55);
|
||||
--sidebar-accent-foreground: oklch(0.18 0.03 45);
|
||||
--sidebar-border: oklch(0.6 0.08 45);
|
||||
--sidebar-ring: oklch(0.55 0.14 45);
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--radius: 0px;
|
||||
--background: #efe5c0;
|
||||
--foreground: #000000;
|
||||
--card: #efe5c0;
|
||||
--card-foreground: #000000;
|
||||
--popover: #efe5c0;
|
||||
--popover-foreground: #000000;
|
||||
--primary: #000000;
|
||||
--primary-foreground: #efe5c0;
|
||||
--secondary: #e0d6b0;
|
||||
--secondary-foreground: #000000;
|
||||
--muted: #e6dcc0;
|
||||
--muted-foreground: #3a3a3a;
|
||||
--accent: #000000;
|
||||
--accent-foreground: #efe5c0;
|
||||
--destructive: #9d0006;
|
||||
--destructive-foreground: #efe5c0;
|
||||
--border: color-mix(in oklab, #000000 22%, transparent);
|
||||
--input: color-mix(in oklab, #000000 30%, transparent);
|
||||
--ring: #000000;
|
||||
--chart-1: #000000;
|
||||
--chart-2: #3a3a3a;
|
||||
--chart-3: #b57614;
|
||||
--chart-4: #79740e;
|
||||
--chart-5: #076678;
|
||||
--sidebar: #efe5c0;
|
||||
--sidebar-foreground: #000000;
|
||||
--sidebar-primary: #000000;
|
||||
--sidebar-primary-foreground: #efe5c0;
|
||||
--sidebar-accent: #e0d6b0;
|
||||
--sidebar-accent-foreground: #000000;
|
||||
--sidebar-border: color-mix(in oklab, #000000 22%, transparent);
|
||||
--sidebar-ring: #000000;
|
||||
--success: #79740e;
|
||||
--warning: #b57614;
|
||||
|
||||
--bg: var(--background);
|
||||
--bg-surface: var(--card);
|
||||
--bg-deeper: oklch(0.98 0.01 55);
|
||||
--bg-deeper: #e6dcc0;
|
||||
--bg-hover: var(--secondary);
|
||||
--bg-active: var(--accent);
|
||||
--text: var(--foreground);
|
||||
--text-muted: var(--muted-foreground);
|
||||
--accent-blue: var(--primary);
|
||||
--accent-blue: #076678;
|
||||
--accent-green: var(--success);
|
||||
--accent-red: var(--destructive);
|
||||
--accent-orange: var(--warning);
|
||||
}
|
||||
|
||||
/* ── Carbon Dark Theme ── */
|
||||
/* ── Cyberspace Dark (warm cream on black) — exact inverse of Light ── */
|
||||
.dark {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.6 0.15 220);
|
||||
--chart-3: oklch(0.5 0.1 160);
|
||||
--chart-4: oklch(0.7 0.08 60);
|
||||
--chart-5: oklch(0.45 0.05 320);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--radius: 0px;
|
||||
--background: #000000;
|
||||
--foreground: #efe5c0;
|
||||
--card: #000000;
|
||||
--card-foreground: #efe5c0;
|
||||
--popover: #000000;
|
||||
--popover-foreground: #efe5c0;
|
||||
--primary: #efe5c0;
|
||||
--primary-foreground: #000000;
|
||||
--secondary: #1a1a1a;
|
||||
--secondary-foreground: #efe5c0;
|
||||
--muted: #141414;
|
||||
--muted-foreground: #a89984;
|
||||
--accent: #efe5c0;
|
||||
--accent-foreground: #000000;
|
||||
--destructive: #cc241d;
|
||||
--destructive-foreground: #efe5c0;
|
||||
--border: color-mix(in oklab, #efe5c0 22%, transparent);
|
||||
--input: color-mix(in oklab, #efe5c0 30%, transparent);
|
||||
--ring: #efe5c0;
|
||||
--chart-1: #efe5c0;
|
||||
--chart-2: #a89984;
|
||||
--chart-3: #fabd2f;
|
||||
--chart-4: #b8bb26;
|
||||
--chart-5: #83a598;
|
||||
--sidebar: #000000;
|
||||
--sidebar-foreground: #efe5c0;
|
||||
--sidebar-primary: #efe5c0;
|
||||
--sidebar-primary-foreground: #000000;
|
||||
--sidebar-accent: #1a1a1a;
|
||||
--sidebar-accent-foreground: #efe5c0;
|
||||
--sidebar-border: color-mix(in oklab, #efe5c0 22%, transparent);
|
||||
--sidebar-ring: #efe5c0;
|
||||
--success: #b8bb26;
|
||||
--warning: #fabd2f;
|
||||
|
||||
--bg: var(--background);
|
||||
--bg-surface: var(--card);
|
||||
--bg-deeper: oklch(0.11 0 0);
|
||||
--bg-deeper: #050505;
|
||||
--bg-hover: var(--secondary);
|
||||
--bg-active: var(--accent);
|
||||
--text: var(--foreground);
|
||||
--text-muted: var(--muted-foreground);
|
||||
--accent-blue: var(--sidebar-primary);
|
||||
--accent-blue: #83a598;
|
||||
--accent-green: var(--success);
|
||||
--accent-red: var(--destructive);
|
||||
--accent-orange: var(--warning);
|
||||
@@ -273,14 +303,17 @@
|
||||
color: var(--card-foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 24px oklch(0 0 0 / 0.18);
|
||||
/* Hard offset shadow (DOS-style), not a soft drop shadow — keeps the
|
||||
border-driven system and still separates stacked windows, which matters
|
||||
because --card now equals the desktop background. */
|
||||
box-shadow: 3px 3px 0 0 var(--border);
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-wm-window][data-wm-focused] {
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 12px 32px oklch(0 0 0 / 0.28);
|
||||
box-shadow: 3px 3px 0 0 var(--ring);
|
||||
}
|
||||
|
||||
[data-wm-window][data-wm-dragging],
|
||||
@@ -417,8 +450,177 @@ a:hover {
|
||||
}
|
||||
.markdown-body a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.markdown-body a:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
/* ── cyberspace idioms (work in any theme; idiomatic for the terminal look) ── */
|
||||
|
||||
/* Pixel/terminal display face for stylized wordmarks & hero titles. Maps to
|
||||
VT323 when available, JetBrains Mono fallback. Headings use --font-heading
|
||||
(JetBrains Mono) by default; opt into this per-element for the "de-imagined"
|
||||
title voice. */
|
||||
.font-vt {
|
||||
font-family: 'VT323', var(--font-mono);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Bordered square card with a 2px fg focus ring — the universal cyberspace
|
||||
surface. Use on any container that wants the terminal-box look. */
|
||||
.terminal-box {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
}
|
||||
.terminal-box:focus-within {
|
||||
box-shadow: 0 0 0 2px var(--ring);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
TERMINAL DESIGN SYSTEM — cyberspace.online adoption
|
||||
These rules are UNLAYERED (written after Tailwind's @layer utilities),
|
||||
so they override utility classes — shadow-*, ring-*, rounded-* — on the
|
||||
shadcn data-slot primitives regardless of specificity. The system is:
|
||||
• border-driven — hairline borders separate surfaces; no soft shadows
|
||||
• square — every corner is 0 (also enforced via --radius tokens)
|
||||
• focus by color — :focus signals via border/text color, not glow rings
|
||||
• DOS modals — dialogs get a double fg-line frame + hatched corner
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
:root {
|
||||
--dos-border: 1px; /* DOS frame line width (used doubled for the modal edge) */
|
||||
--dos-dither: 4px; /* hatch tile size for the modal corner shadow */
|
||||
--dos-offset: 7px; /* how far the hatched corner sits out from the frame */
|
||||
}
|
||||
|
||||
/* ── Containers → terminal-box: solid hairline border, square, no shadow.
|
||||
Replaces shadcn's `shadow-xs ring-1 ring-foreground/10 rounded-xl/md`. ── */
|
||||
[data-slot='card'],
|
||||
[data-slot='popover-content'],
|
||||
[data-slot='hover-card-content'],
|
||||
[data-slot='dropdown-menu-content'],
|
||||
[data-slot='select-content'],
|
||||
[data-slot='tooltip-content'],
|
||||
[data-slot='sheet-content'],
|
||||
[data-slot='menubar-content'],
|
||||
[data-slot='alert-dialog-content'] {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Dialogs → the DOS frame. A 1px fg border, then a bg gap, then a 1px fg
|
||||
ring (two stacked box-shadows) = the double-line terminal window edge. ── */
|
||||
[data-slot='dialog-content'] {
|
||||
position: fixed;
|
||||
border: var(--dos-border) solid var(--foreground);
|
||||
border-radius: 0;
|
||||
box-shadow:
|
||||
0 0 0 var(--dos-border) var(--background),
|
||||
0 0 0 calc(var(--dos-border) * 2) var(--foreground);
|
||||
}
|
||||
/* Hatched corner shadow (fg-dim 45° checks) bottom-right — the DOS window
|
||||
tell. Clipped to a small square outside the frame. */
|
||||
[data-slot='dialog-content']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: calc(-1 * var(--dos-offset));
|
||||
bottom: calc(-1 * var(--dos-offset));
|
||||
width: var(--dos-offset);
|
||||
height: var(--dos-offset);
|
||||
pointer-events: none;
|
||||
background-color: var(--muted-foreground);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
45deg,
|
||||
var(--background) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
var(--background) 75%
|
||||
),
|
||||
linear-gradient(
|
||||
45deg,
|
||||
var(--background) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
var(--background) 75%
|
||||
);
|
||||
background-size: var(--dos-dither) var(--dos-dither);
|
||||
background-position:
|
||||
0 0,
|
||||
calc(var(--dos-dither) / 2) calc(var(--dos-dither) / 2);
|
||||
}
|
||||
|
||||
/* ── Overlays: opaque-ish theme background, no blur (terminal, not glass). ── */
|
||||
[data-slot='dialog-overlay'],
|
||||
[data-slot='alert-dialog-overlay'],
|
||||
[data-slot='sheet-overlay'] {
|
||||
background-color: color-mix(in oklab, var(--background) 82%, transparent);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* ── Inputs: square, no resting shadow; focus = solid fg border (cyberspace
|
||||
signals focus by border color, not a glow ring). ── */
|
||||
[data-slot='input'],
|
||||
[data-slot='textarea'] {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
[data-slot='input']:focus,
|
||||
[data-slot='input']:focus-visible,
|
||||
[data-slot='textarea']:focus,
|
||||
[data-slot='textarea']:focus-visible {
|
||||
border-color: var(--foreground);
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Buttons: square, no resting shadow. Focus keeps the cva border-color
|
||||
shift to fg (focus-visible:border-ring) rather than a ring. ── */
|
||||
[data-slot='button'] {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Badges & tabs: square. ── */
|
||||
[data-slot='badge'] {
|
||||
border-radius: 0;
|
||||
}
|
||||
[data-slot='tabs-trigger'],
|
||||
[data-slot='tabs-list'] {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ── Window controls (titlebar minimize/maximize/close + taskbar item close).
|
||||
ONE style across all window chrome: bordered square, invert on hover — the
|
||||
same idiom as desktop icons and taskbar buttons. Close is intentionally not
|
||||
"danger"-colored so every control reads as the same component. ── */
|
||||
.win-ctrl {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--background);
|
||||
color: var(--muted-foreground);
|
||||
transition:
|
||||
color 0.12s,
|
||||
border-color 0.12s,
|
||||
background 0.12s;
|
||||
}
|
||||
.win-ctrl:hover {
|
||||
border-color: var(--foreground);
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
/* Pin every window titlebar to one exact height, targeted by attribute so it
|
||||
holds for every window regardless of its titlebar classes — including
|
||||
already-open wmkit windows whose titlebar markup can lag behind on HMR
|
||||
(component HMR is unreliable for wmkit windows; CSS HMR is not).
|
||||
`flex: 0 0 2.25rem` is the strongest guarantee a flex item won't grow or
|
||||
shrink — it stops a scrolling/growing content pane from compressing the
|
||||
titlebar (the chat-window symptom). */
|
||||
[data-wm-drag] {
|
||||
flex: 0 0 2.25rem !important;
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<script lang="ts">
|
||||
// The agent's working trace for one assistant turn: the live "thinking"
|
||||
// indicator and that turn's tool calls merged into a single collapsible
|
||||
// strip, instead of a stack of one card per call (a 13-call turn buried the
|
||||
// actual answer). Collapsed it's one line — the current activity while
|
||||
// running, a count once finished. Expanded it lists what the agent did, in
|
||||
// humanized language, each row opening to its raw args/result.
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
import ToolCallCard from './ToolCallCard.svelte'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
|
||||
let {
|
||||
tools = [],
|
||||
label = null,
|
||||
status = 'idle'
|
||||
}: {
|
||||
tools?: ToolCallResult[]
|
||||
/** Live indicator text — the running step, an error, or "Done". */
|
||||
label?: string | null
|
||||
/** `idle` = no live state; the strip is just this turn's finished trace. */
|
||||
status?: 'running' | 'done' | 'error' | 'idle'
|
||||
} = $props()
|
||||
|
||||
let expanded = $state(false)
|
||||
|
||||
const count = $derived(tools.length)
|
||||
// Collapsed line: prefer the live activity while something is happening,
|
||||
// otherwise summarize the turn so a finished trace still says what it was.
|
||||
const headline = $derived.by(() => {
|
||||
if (status !== 'idle' && label) return label
|
||||
if (count > 0) return count === 1 ? '1 tool call' : `${count} tool calls`
|
||||
return 'No tool calls'
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="trace rounded-lg border border-border/60 bg-card/40 transition-colors"
|
||||
class:running={status === 'running'}
|
||||
>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/40"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? 'Hide agent trace' : 'Show agent trace'}
|
||||
>
|
||||
<span
|
||||
class="shrink-0 {status === 'error'
|
||||
? 'text-destructive'
|
||||
: status === 'idle'
|
||||
? 'text-muted-foreground'
|
||||
: 'text-primary'}"
|
||||
>
|
||||
{#if status === 'running'}
|
||||
<Spinner class="size-3" />
|
||||
{:else if status === 'error'}
|
||||
<XIcon class="size-3" />
|
||||
{:else if status === 'done'}
|
||||
<CheckIcon class="size-3" />
|
||||
{:else}
|
||||
<SparklesIcon class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-xs {status === 'error'
|
||||
? 'text-destructive'
|
||||
: status === 'running'
|
||||
? 'text-foreground/80'
|
||||
: 'text-muted-foreground'}"
|
||||
>
|
||||
{headline}
|
||||
</span>
|
||||
|
||||
{#if count > 0 && status !== 'idle'}
|
||||
<span class="shrink-0 text-[10px] tabular-nums text-muted-foreground/60">{count}</span>
|
||||
{/if}
|
||||
|
||||
<ChevronRightIcon
|
||||
class="size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="border-t border-border/40 p-1">
|
||||
{#if count > 0}
|
||||
{#each tools as tool (tool.id)}
|
||||
<ToolCallCard {tool} />
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||
Nothing recorded for this turn yet.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.trace {
|
||||
animation: trace-in 0.2s ease-out;
|
||||
}
|
||||
/* A faint pulse while the agent is mid-turn — the collapsed strip is the
|
||||
only thing on screen then, so it carries the "still working" signal. */
|
||||
.trace.running {
|
||||
border-color: color-mix(in oklab, var(--primary) 35%, var(--border));
|
||||
}
|
||||
@keyframes trace-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trace {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,24 +6,32 @@
|
||||
// instead of being copy-pasted between the two.
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
|
||||
import { resumeSession } from '$lib/api'
|
||||
import type { Readable } from 'svelte/store'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import AgentTrace from './AgentTrace.svelte'
|
||||
import TurnTrace from './TurnTrace.svelte'
|
||||
import ThinkingBlock from './ThinkingBlock.svelte'
|
||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||
import GlyphIndicator from './GlyphIndicator.svelte'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
||||
import SquareIcon from '@lucide/svelte/icons/square'
|
||||
import CopyIcon from '@lucide/svelte/icons/copy'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import ArrowDownToLineIcon from '@lucide/svelte/icons/arrow-down-to-line'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import type { ChatMessage } from '$lib/stores/chat'
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
import type { SessionQuestion } from '$lib/api'
|
||||
import type { PlanStep, SessionQuestion } from '$lib/api'
|
||||
|
||||
let {
|
||||
messages,
|
||||
streaming,
|
||||
connectionState,
|
||||
working = false,
|
||||
error = null,
|
||||
chatErrors = [],
|
||||
onSend,
|
||||
@@ -34,11 +42,20 @@
|
||||
activityLog: activityLogProp = activityLog,
|
||||
sessionId = null,
|
||||
question = null,
|
||||
initialDraft = ''
|
||||
initialDraft = '',
|
||||
planSteps = [],
|
||||
taskStatus,
|
||||
lastActiveAt
|
||||
}: {
|
||||
messages: ChatMessage[]
|
||||
streaming: boolean
|
||||
connectionState: 'connected' | 'disconnected' | 'reconnecting'
|
||||
/** True while a turn is running for this session — a live stream OR the
|
||||
* server-side status says planning/executing. Drives the "working"
|
||||
* indicator so a background/long/desynced turn still looks alive. The
|
||||
* literal `streaming` (live deltas) is still used for the cursor + input
|
||||
* lock. See plan 2026-08-03 F1. */
|
||||
working?: boolean
|
||||
error?: string | null
|
||||
chatErrors?: { id: string; message: string; action?: string }[]
|
||||
onSend: (text: string) => void
|
||||
@@ -56,26 +73,35 @@
|
||||
* than making them retype it. Left editable on purpose — it is a starting
|
||||
* point, not a command. */
|
||||
initialDraft?: string
|
||||
/** Current-generation plan steps for this session — rendered as a live
|
||||
* checklist on the running turn (TodoWrite-style). Empty for a new/plan-less
|
||||
* task and for the new-task launcher. */
|
||||
planSteps?: PlanStep[]
|
||||
/** Session status (active/planning/executing/…/done/failed). Drives the
|
||||
* plan checklist's collapse-to-summary at a terminal state. */
|
||||
taskStatus?: string
|
||||
/** Session's last_active_at timestamp — used to detect a stuck turn
|
||||
* (working but no activity for >5 min) and show elapsed time. */
|
||||
lastActiveAt?: string
|
||||
} = $props()
|
||||
|
||||
let input = $state(initialDraft)
|
||||
let messagesEnd = $state<HTMLDivElement | null>(null)
|
||||
let input = $state(typeof initialDraft === 'string' ? initialDraft : '')
|
||||
let scrolledUp = $state(false)
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
|
||||
let indicatorDone = $state(false)
|
||||
let wasStreaming = $state(false)
|
||||
let wasWorking = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (streaming) {
|
||||
if (working) {
|
||||
indicatorDone = false
|
||||
wasStreaming = true
|
||||
wasWorking = true
|
||||
}
|
||||
if (!streaming && wasStreaming) {
|
||||
if (!working && wasWorking) {
|
||||
indicatorDone = true
|
||||
const t = setTimeout(() => {
|
||||
indicatorDone = false
|
||||
wasStreaming = false
|
||||
wasWorking = false
|
||||
}, 3000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
@@ -83,7 +109,7 @@
|
||||
|
||||
const indicatorLabel = $derived.by(() => {
|
||||
if (error) return error
|
||||
if (!streaming && indicatorDone) return 'Done'
|
||||
if (!working && indicatorDone) return 'Done'
|
||||
// Prefer the running PLAN STEP as the headline — it's stable across the
|
||||
// step's many tool calls, so the line stops rewriting itself on every
|
||||
// command (the "thinking overwrites itself" complaint, F6). Falls back to
|
||||
@@ -96,6 +122,63 @@
|
||||
return 'Agent is thinking…'
|
||||
})
|
||||
|
||||
// ── stuck detection + elapsed time ─────────────────────────────────────
|
||||
// A turn is "stuck" when the server says working (planning/executing) but
|
||||
// last_active_at is >5 min old — the agent's turn ended without updating
|
||||
// the session status (crash, timeout, or a zombie gate). Show a distinct
|
||||
// stuck indicator with a Resume button instead of a misleading "working…".
|
||||
let resuming = $state(false)
|
||||
let now = $state(Date.now())
|
||||
|
||||
$effect(() => {
|
||||
if (!working) return
|
||||
const id = setInterval(() => {
|
||||
now = Date.now()
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
})
|
||||
|
||||
const elapsedSeconds = $derived(
|
||||
working && lastActiveAt
|
||||
? Math.max(0, Math.floor((now - new Date(lastActiveAt).getTime()) / 1000))
|
||||
: 0
|
||||
)
|
||||
const isStuck = $derived(working && !streaming && elapsedSeconds > 300)
|
||||
|
||||
function formatElapsed(s: number): string {
|
||||
if (s < 60) return `${s}s`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`
|
||||
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
||||
}
|
||||
|
||||
async function handleResume() {
|
||||
if (!sessionId || resuming) return
|
||||
resuming = true
|
||||
try {
|
||||
await resumeSession(sessionId)
|
||||
} finally {
|
||||
resuming = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── glyph backdrop ─────────────────────────────────────────────────────
|
||||
// The agent's live semantic state, rendered as a faint procedural glyph
|
||||
// behind the transcript. Computed from the same signals as the sidebar
|
||||
// (status / working / streaming / connection / stuck) so the backdrop
|
||||
// breathes with the agent without any store imports (prop-driven).
|
||||
const agentSprite = $derived.by(() => {
|
||||
if (connectionState !== 'connected') return 'status.offline'
|
||||
if (taskStatus === 'failed') return 'status.error'
|
||||
if (taskStatus === 'abandoned') return 'status.cancelled'
|
||||
if (taskStatus === 'done') return 'status.success'
|
||||
if (taskStatus === 'awaiting_input') return 'ai.listening'
|
||||
if (isStuck) return 'status.warning'
|
||||
if (streaming) return 'ai.speaking'
|
||||
if (working) return 'ai.still-working'
|
||||
if (taskStatus === 'planning') return 'ai.thinking'
|
||||
return 'ai.idle'
|
||||
})
|
||||
|
||||
// Resizable input area — drag the splitter above it to grow the textarea,
|
||||
// capped so it can't swallow the whole thread. Both the minimum and the
|
||||
// default are exactly one line: measured from the textarea's own
|
||||
@@ -150,12 +233,24 @@
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom on new messages (or a freshly-raised question) —
|
||||
// unless user scrolled up to read.
|
||||
// unless user scrolled up to read. Sets scrollTop on the messages container
|
||||
// directly instead of `scrollIntoView`, which walks ancestors and forces a
|
||||
// reflow that can momentarily perturb the window titlebar height.
|
||||
//
|
||||
// During streaming, scroll INSTANTLY (behavior: 'auto') — the content is
|
||||
// growing continuously, so a smooth animation constantly chases a moving
|
||||
// target and produces the jerky "jumping" the operator sees. For
|
||||
// non-streaming updates (a completed message, a question), a smooth scroll
|
||||
// is fine. Uses requestAnimationFrame so the scroll lands after the DOM
|
||||
// update, not 50ms later.
|
||||
$effect(() => {
|
||||
void messages
|
||||
void question
|
||||
if (streaming || !scrolledUp) {
|
||||
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
|
||||
const behavior = streaming ? ('auto' as const) : ('smooth' as const)
|
||||
requestAnimationFrame(() => {
|
||||
container?.scrollTo({ top: container.scrollHeight, behavior })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -204,25 +299,51 @@
|
||||
onSend(q)
|
||||
}
|
||||
|
||||
// Merge live streaming output (from the activity log's `run` entry) onto the
|
||||
// in-flight turn's tool calls so the inline tool card shows command output as
|
||||
// it arrives — the place the operator naturally "checks the tool". Only the
|
||||
// last assistant message can be streaming, so only it gets enriched; history
|
||||
// is untouched (and has no live output anyway). (F4)
|
||||
function toolsWithLive(
|
||||
tools: ToolCallResult[],
|
||||
entries: ActivityEntry[],
|
||||
isLiveTurn: boolean
|
||||
): ToolCallResult[] {
|
||||
if (!isLiveTurn) return tools
|
||||
const liveById = new Map<string, string>()
|
||||
for (const e of entries) {
|
||||
if (e.liveOutput && e.id) liveById.set(e.id, e.liveOutput)
|
||||
// Per-message copy affordance (border-driven icon button on each row).
|
||||
let copiedId = $state<string | null>(null)
|
||||
async function copyMessage(msg: ChatMessage) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(msg.text)
|
||||
copiedId = msg.id
|
||||
setTimeout(() => {
|
||||
if (copiedId === msg.id) copiedId = null
|
||||
}, 1400)
|
||||
} catch {
|
||||
/* clipboard unavailable — silently no-op */
|
||||
}
|
||||
if (liveById.size === 0) return tools
|
||||
return tools.map((t) =>
|
||||
t.id && liveById.has(t.id) ? { ...t, liveOutput: liveById.get(t.id) } : t
|
||||
)
|
||||
}
|
||||
|
||||
// Scroll-to-bottom: uses container.scrollTo (never scrollIntoView, which
|
||||
// reflows ancestor wmkit panes — see wmkit.scrollintoview_reflow_pitfall).
|
||||
function jumpToBottom() {
|
||||
container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' })
|
||||
scrolledUp = false
|
||||
}
|
||||
|
||||
// Enrich a turn's tool calls with live `run` output AND plan-step
|
||||
// attribution pulled from the activity log (keyed by tool id), so the inline
|
||||
// TurnTrace can pin streaming output to its tool and group calls under their
|
||||
// step. Run for every turn (not just the live one) so historical turns group
|
||||
// correctly too; unmapped tools pass through unchanged.
|
||||
function enrichTools(tools: ToolCallResult[], entries: ActivityEntry[]): ToolCallResult[] {
|
||||
const byId = new Map<string, { liveOutput?: string; stepSeq?: number }>()
|
||||
for (const e of entries) {
|
||||
if (!e.id) continue
|
||||
const cur = byId.get(e.id) ?? {}
|
||||
if (e.liveOutput) cur.liveOutput = e.liveOutput
|
||||
if (e.stepSeq != null) cur.stepSeq = e.stepSeq
|
||||
byId.set(e.id, cur)
|
||||
}
|
||||
if (byId.size === 0) return tools
|
||||
return tools.map((t) => {
|
||||
if (!t.id) return t
|
||||
const e = byId.get(t.id)
|
||||
if (!e) return t
|
||||
const next: ToolCallResult = { ...t }
|
||||
if (e.liveOutput) next.liveOutput = e.liveOutput
|
||||
if (e.stepSeq != null) next.stepSeq = e.stepSeq
|
||||
return next
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -235,99 +356,117 @@
|
||||
on:resize={() => (userResizedInput = true)}
|
||||
>
|
||||
<Pane class="flex flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||
<div class="mx-auto flex min-h-full max-w-3xl flex-col gap-5 p-4">
|
||||
{#if messages.length === 0}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-6 text-center">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Nomos</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Your resident operator. Ask about the fleet, or tell it to act.
|
||||
</p>
|
||||
</div>
|
||||
{#if suggestions.length}
|
||||
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{#each suggestions as q}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-auto justify-start whitespace-normal py-2 text-left text-xs"
|
||||
onclick={() => ask(q)}
|
||||
>
|
||||
{q}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as msg, idx (msg.id)}
|
||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
{#if msg.role === 'user'}
|
||||
<div class="flex items-baseline gap-2 px-1">
|
||||
<span class="text-[10px] font-medium text-muted-foreground/70">You</span>
|
||||
{#if msg.created_at}
|
||||
<span class="text-[9px] text-muted-foreground/50"
|
||||
>{formatTime(msg.created_at)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg"
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
{:else}
|
||||
{@const isLast = idx === messages.length - 1}
|
||||
{@const traceStatus = !isLast
|
||||
? 'idle'
|
||||
: error
|
||||
? 'error'
|
||||
: streaming
|
||||
? 'running'
|
||||
: indicatorDone
|
||||
? 'done'
|
||||
: 'idle'}
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex items-baseline gap-2 px-1">
|
||||
<span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
|
||||
{#if msg.created_at}
|
||||
<span class="text-[9px] text-muted-foreground/50"
|
||||
>{formatTime(msg.created_at)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- The working trace sits above the answer: it's what happened
|
||||
first, and collapsed it keeps a long tool run from burying
|
||||
the text below it. -->
|
||||
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
|
||||
<AgentTrace
|
||||
tools={toolsWithLive(msg.tools, $activityLogProp, isLast && traceStatus !== 'idle')}
|
||||
status={traceStatus}
|
||||
label={traceStatus === 'idle' ? null : indicatorLabel}
|
||||
/>
|
||||
{/if}
|
||||
{#if msg.text}
|
||||
<div
|
||||
class="markdown-body prose-chat max-w-none text-sm leading-relaxed assistant-msg"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
{@html render(msg.text)}
|
||||
{#if isLast && streaming}
|
||||
<span class="stream-cursor" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if question}
|
||||
<OperatorQuestion {sessionId} {question} />
|
||||
{/if}
|
||||
<div bind:this={messagesEnd}></div>
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<!-- Glyph backdrop — the agent's live semantic state as a faint
|
||||
procedural watermark behind the transcript. Fixed (doesn't scroll
|
||||
with the messages), pointer-events none, behind the content. -->
|
||||
<div class="glyph-backdrop" aria-hidden="true">
|
||||
<GlyphIndicator sprite={agentSprite} seed={sessionId ?? 'oikos'} size={640} opacity={0.07} />
|
||||
</div>
|
||||
<div class="relative z-[1] h-full overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||
<div class="mx-auto flex min-h-full max-w-3xl flex-col divide-y divide-border px-4">
|
||||
{#if messages.length === 0}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-8 text-center">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Nomos</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Your resident operator. Ask about the fleet, or tell it to act.
|
||||
</p>
|
||||
</div>
|
||||
{#if suggestions.length}
|
||||
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{#each suggestions as q}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-auto justify-start whitespace-normal py-2 text-left text-xs"
|
||||
onclick={() => ask(q)}
|
||||
>
|
||||
{q}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as msg, idx (msg.id)}
|
||||
{@const isLast = idx === messages.length - 1}
|
||||
<div class="msg-row relative flex gap-3 py-3">
|
||||
<div class="msg-role" aria-hidden="true">{msg.role === 'user' ? 'YOU' : 'NOMOS'}</div>
|
||||
<div class="msg-body min-w-0 flex-1">
|
||||
{#if msg.role === 'user'}
|
||||
<div class="user-text whitespace-pre-wrap text-sm leading-relaxed">{msg.text}</div>
|
||||
{#if msg.created_at}
|
||||
<div class="msg-time">{formatTime(msg.created_at)}</div>
|
||||
{/if}
|
||||
{#if isLast && working && !streaming}
|
||||
<!-- The last message is this user row and the agent is working but not
|
||||
live-streaming → the message was queued behind an in-flight turn
|
||||
(plan 2026-08-03 F2). It'll run when the current step finishes. -->
|
||||
<div class="queued-hint">Queued — runs when Nomos finishes the current step.</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const traceStatus = !isLast
|
||||
? 'idle'
|
||||
: error
|
||||
? 'error'
|
||||
: working
|
||||
? 'running'
|
||||
: indicatorDone
|
||||
? 'done'
|
||||
: 'idle'}
|
||||
<!-- Inline progressive trace: live plan checklist (last turn) +
|
||||
thinking line + per-tool lines, then the streamed answer. -->
|
||||
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
|
||||
<TurnTrace
|
||||
tools={enrichTools(msg.tools, $activityLogProp)}
|
||||
status={traceStatus}
|
||||
label={traceStatus === 'idle' ? null : indicatorLabel}
|
||||
{isLast}
|
||||
planSteps={isLast ? planSteps : []}
|
||||
{taskStatus}
|
||||
/>
|
||||
{/if}
|
||||
{#if msg.thinking}
|
||||
<ThinkingBlock thinking={msg.thinking} />
|
||||
{/if}
|
||||
{#if msg.text}
|
||||
<div
|
||||
class="markdown-body prose-chat max-w-none text-sm leading-relaxed"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
{@html render(msg.text)}
|
||||
{#if isLast && streaming}
|
||||
<span class="stream-cursor" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if msg.created_at}
|
||||
<div class="msg-time">{formatTime(msg.created_at)}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="msg-action"
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
onclick={() => copyMessage(msg)}
|
||||
>
|
||||
{#if copiedId === msg.id}<CheckIcon class="size-3.5" />{:else}<CopyIcon class="size-3.5" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if question}
|
||||
<div class="py-3"><OperatorQuestion {sessionId} {question} /></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if scrolledUp && messages.length > 0}
|
||||
<button class="jump-bottom" onclick={jumpToBottom} aria-label="Jump to latest">
|
||||
<ArrowDownToLineIcon class="size-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if connectionState === 'disconnected'}
|
||||
@@ -384,16 +523,52 @@
|
||||
<button
|
||||
class="ml-1 text-muted-foreground hover:text-foreground"
|
||||
onclick={() => onDismissError(err.id)}
|
||||
aria-label="Dismiss">×</button
|
||||
aria-label="Dismiss">×</button>
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if working && !streaming}
|
||||
<!-- Background/autonomous turn in progress (no live stream to watch):
|
||||
keep the composer open so the operator can queue a follow-up
|
||||
(plan 2026-08-03 F1/F2). Lives in the message pane (alongside the
|
||||
connection/error banners) so it consumes transcript space, NOT the
|
||||
input pane's fixed height — otherwise appearing/disappearing would
|
||||
clip the textarea and force a resize. Terminal status strip:
|
||||
spinner + fg label + primary-tinted hairline border, aligned to the
|
||||
textarea column. -->
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
{#if isStuck}
|
||||
<div class="composer-status composer-status-stuck mb-2">
|
||||
<span class="composer-status-label stuck-label">Stuck</span>
|
||||
<span class="composer-status-text"
|
||||
>no activity for {formatElapsed(elapsedSeconds)}</span
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
class="h-6 text-[11px]"
|
||||
onclick={handleResume}
|
||||
disabled={resuming}
|
||||
>
|
||||
{resuming ? 'Resuming…' : 'Resume'}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="composer-status mb-2">
|
||||
<Spinner class="size-3 shrink-0 text-primary" />
|
||||
<span class="composer-status-label">Working</span>
|
||||
<span class="composer-status-text">{formatElapsed(elapsedSeconds)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
|
||||
<Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
|
||||
<div
|
||||
class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative"
|
||||
class="flex h-full min-h-0 flex-col border-t bg-background p-3 input-ornament relative"
|
||||
bind:this={inputWrapperRef}
|
||||
>
|
||||
<form
|
||||
@@ -408,7 +583,7 @@
|
||||
bind:value={input}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Ask Nomos anything…"
|
||||
class="h-full max-h-none min-h-0 resize-none rounded-2xl px-4 py-3 pr-12 field-sizing-fixed"
|
||||
class="h-full max-h-none min-h-0 resize-none px-4 py-3 pr-12 field-sizing-fixed"
|
||||
disabled={streaming}
|
||||
/>
|
||||
{#if streaming}
|
||||
@@ -416,7 +591,7 @@
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
class="absolute right-2 bottom-2 rounded-lg"
|
||||
class="absolute right-2 bottom-2"
|
||||
onclick={onCancel}
|
||||
aria-label="Stop"
|
||||
>
|
||||
@@ -427,7 +602,7 @@
|
||||
type="submit"
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
class="absolute right-2 bottom-2 rounded-lg"
|
||||
class="absolute right-2 bottom-2"
|
||||
disabled={!input.trim()}
|
||||
aria-label="Send"
|
||||
>
|
||||
@@ -441,34 +616,110 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Art Nouveau chat styling ── */
|
||||
/* ── Cyberspace / terminal chat styling ──
|
||||
Messages are full-width terminal log rows (left role-tag column +
|
||||
content), separated by hairline divide-y. Border-driven, square, no soft
|
||||
shadows — same language as the rest of the app. Prose deltas below sit on
|
||||
top of the shared .markdown-body base (app.css); a two-class selector
|
||||
(`.markdown-body.prose-chat`) wins on specificity over app.css's single
|
||||
`.markdown-body` rules deterministically, regardless of <style> injection
|
||||
order. */
|
||||
|
||||
/* Assistant message wrapper */
|
||||
.assistant-msg {
|
||||
position: relative;
|
||||
/* Message rows */
|
||||
.msg-row {
|
||||
/* role column + body; the copy action is absolutely positioned top-right */
|
||||
}
|
||||
|
||||
/* User message — soft terracotta bubble, gentle lift */
|
||||
.user-msg {
|
||||
box-shadow: 0 1px 8px -4px var(--primary);
|
||||
.msg-role {
|
||||
flex-shrink: 0;
|
||||
width: 3.25rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted-foreground);
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
.msg-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.user-text {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.msg-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.queued-hint {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.msg-action {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.12s,
|
||||
color 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
.msg-row:hover .msg-action,
|
||||
.msg-action:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.msg-action:hover {
|
||||
color: var(--foreground);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* Prose overrides — deltas on top of the shared .markdown-body base
|
||||
(app.css) only. The template applies both classes together
|
||||
(class="markdown-body prose-chat ..."); everything below either adds a
|
||||
look .markdown-body doesn't have (li::marker, the pre/blockquote
|
||||
::before ornaments, hr, strong, the table-wrapper, the code-copy
|
||||
button) or overrides a .markdown-body value that this "Art Nouveau"
|
||||
chat treatment wants different (code/pre padding, heading size, th/td
|
||||
padding, blockquote border color, link underline style). Anywhere a
|
||||
value is actually overridden, the selector is
|
||||
`.markdown-body.prose-chat` rather than `.prose-chat` alone —
|
||||
:global() selectors from two different <style> blocks land in the same
|
||||
stylesheet with no scoping to arbitrate between them, so equal
|
||||
specificity would leave the winner to injection order (unreliable
|
||||
across dev/build). The two-class selector's higher specificity wins
|
||||
deterministically regardless. */
|
||||
/* Glyph backdrop — fills the message pane, centers the glyph, stays put
|
||||
while the transcript scrolls over it. pointer-events none so it never
|
||||
intercepts scroll/click. */
|
||||
.glyph-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Jump-to-latest button — border-driven square, sits over the transcript */
|
||||
.jump-bottom {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--background);
|
||||
color: var(--muted-foreground);
|
||||
box-shadow: 2px 2px 0 0 var(--border);
|
||||
}
|
||||
.jump-bottom:hover {
|
||||
color: var(--foreground);
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Prose deltas (markdown-body base lives in app.css). */
|
||||
.prose-chat :global(li) {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
@@ -501,8 +752,8 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
||||
margin separates sections; the first heading in a message doesn't. */
|
||||
/* Headings — short accent rule under each; first heading in a message
|
||||
doesn't get extra top margin. */
|
||||
.markdown-body.prose-chat :global(h1) {
|
||||
font-size: 1.15em;
|
||||
margin: 1.15rem 0 0.4rem;
|
||||
@@ -537,7 +788,6 @@
|
||||
width: 2.5rem;
|
||||
height: 2px;
|
||||
margin-top: 4px;
|
||||
border-radius: 1px;
|
||||
background: linear-gradient(to right, var(--primary), transparent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
@@ -564,7 +814,7 @@
|
||||
position: relative;
|
||||
}
|
||||
.prose-chat :global(blockquote)::before {
|
||||
content: '“';
|
||||
content: '"';
|
||||
position: absolute;
|
||||
left: -0.15rem;
|
||||
top: -0.35rem;
|
||||
@@ -588,8 +838,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
|
||||
terracotta accent stay meaningful (code, headings, links). */
|
||||
.prose-chat :global(strong) {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
@@ -601,7 +849,7 @@
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Input area ornament */
|
||||
/* Input area hairline ornament */
|
||||
.input-ornament::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -613,7 +861,38 @@
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Code copy button — global: injected via render() into {html} blocks */
|
||||
/* Composer "working/queued" status strip — terminal status-bar idiom: a
|
||||
hairline primary-tinted border, square corners, a spinner + an uppercase
|
||||
fg label + muted detail. Border-driven, no shadow. */
|
||||
.composer-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 35%, var(--border));
|
||||
border-radius: 0;
|
||||
background: color-mix(in oklab, var(--primary) 6%, var(--card));
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.composer-status-label {
|
||||
color: var(--foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.composer-status-text {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.composer-status-stuck {
|
||||
border-color: color-mix(in oklab, var(--warning) 50%, var(--border));
|
||||
background: color-mix(in oklab, var(--warning) 8%, var(--card));
|
||||
}
|
||||
.stuck-label {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* Code copy button — global: injected via render() into {html} blocks.
|
||||
Square (cyberspace), not rounded. */
|
||||
.prose-chat :global(.code-block-wrapper) {
|
||||
position: relative;
|
||||
}
|
||||
@@ -626,7 +905,6 @@
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.375rem;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0;
|
||||
transition:
|
||||
@@ -651,7 +929,6 @@
|
||||
height: 1.1em;
|
||||
background: var(--primary);
|
||||
opacity: 0.75;
|
||||
border-radius: 1px;
|
||||
margin-left: 1px;
|
||||
vertical-align: text-bottom;
|
||||
animation: cursor-blink 0.9s ease-in-out infinite;
|
||||
@@ -666,4 +943,12 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stream-cursor,
|
||||
.msg-action {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
// pulse brightness
|
||||
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
|
||||
ctx.fillStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
|
||||
ctx.fillStyle = dark ? `rgba(168,153,132,${alpha})` : `rgba(58,58,58,${alpha})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
@@ -123,7 +123,7 @@
|
||||
const dist = dx * dx + dy * dy
|
||||
if (dist < CONNECT_DIST * CONNECT_DIST) {
|
||||
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
|
||||
ctx.strokeStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
|
||||
ctx.strokeStyle = dark ? `rgba(168,153,132,${alpha})` : `rgba(58,58,58,${alpha})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.lineTo(b.x, b.y)
|
||||
@@ -136,7 +136,7 @@
|
||||
const cx = w / 2,
|
||||
cy = h / 2
|
||||
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
||||
const base = dark ? '13,17,23' : '255,255,255'
|
||||
const base = dark ? '0,0,0' : '239,229,192'
|
||||
scrim.addColorStop(0, `rgba(${base},0.72)`)
|
||||
scrim.addColorStop(0.35, `rgba(${base},0.40)`)
|
||||
scrim.addColorStop(0.65, `rgba(${base},0.08)`)
|
||||
|
||||
@@ -1101,7 +1101,7 @@
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
@@ -1114,9 +1114,12 @@
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.chip.on {
|
||||
color: var(--foreground);
|
||||
background: var(--secondary);
|
||||
border-color: var(--primary);
|
||||
color: var(--background);
|
||||
background: var(--foreground);
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
.chip.on b {
|
||||
color: var(--background);
|
||||
}
|
||||
.chip .dot {
|
||||
width: 8px;
|
||||
|
||||
89
web/src/lib/components/GlyphIndicator.svelte
Normal file
89
web/src/lib/components/GlyphIndicator.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import {
|
||||
createGlyph,
|
||||
type JoanGlyphEngine,
|
||||
type SpriteName
|
||||
} from '@joan/procedural-glyph-engine'
|
||||
import { getTheme } from '$lib/stores/theme.svelte'
|
||||
|
||||
let {
|
||||
sprite,
|
||||
seed = 'oikos',
|
||||
size = 96,
|
||||
opacity = 1
|
||||
}: {
|
||||
sprite: string
|
||||
seed?: string
|
||||
/** Display max-width in px (the engine's internal grid stays 96; CSS
|
||||
* upscales pixelated for larger backdrops). */
|
||||
size?: number
|
||||
/** Canvas opacity — <1 for a faint watermark backdrop. */
|
||||
opacity?: number
|
||||
} = $props()
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||
let glyph = $state<JoanGlyphEngine | null>(null)
|
||||
|
||||
function palette(t: 'light' | 'dark') {
|
||||
return {
|
||||
background: 'transparent',
|
||||
off: t === 'dark' ? '#1a1a1a' : '#e6dcc0',
|
||||
ink: t === 'dark' ? '#efe5c0' : '#000000',
|
||||
accent: t === 'dark' ? '#a89984' : '#3a3a3a',
|
||||
glow: 'transparent'
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const g = createGlyph(canvas!, {
|
||||
sprite: sprite as SpriteName,
|
||||
seed,
|
||||
gridSize: 96,
|
||||
palette: palette(getTheme()),
|
||||
background: false,
|
||||
orbBackgroundColor: 'transparent',
|
||||
orbBackgroundMode: 'none'
|
||||
})
|
||||
glyph = g
|
||||
|
||||
const obs = new MutationObserver(() => {
|
||||
g.configure({ palette: palette(getTheme()) })
|
||||
})
|
||||
obs.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
})
|
||||
|
||||
return () => obs.disconnect()
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
glyph?.destroy()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (glyph && sprite) {
|
||||
glyph.transitionTo(sprite as SpriteName)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
width="96"
|
||||
height="96"
|
||||
class="glyph"
|
||||
style="max-width:{size}px;opacity:{opacity}"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
|
||||
<style>
|
||||
.glyph {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
margin: 0 auto;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
</style>
|
||||
206
web/src/lib/components/RasterImage.svelte
Normal file
206
web/src/lib/components/RasterImage.svelte
Normal file
@@ -0,0 +1,206 @@
|
||||
<script lang="ts" module>
|
||||
// Cached dither result for the current source/size, so a theme change only
|
||||
// re-paints (cheap) instead of re-running the error-diffusion pass.
|
||||
export interface DitherCache {
|
||||
key: string
|
||||
bits: Uint8Array // 1 = light (paper), 0 = dark (ink)
|
||||
alpha: Uint8Array
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
let {
|
||||
src,
|
||||
alt = '',
|
||||
width = 256,
|
||||
class: className = '',
|
||||
plain = false,
|
||||
bias = 0
|
||||
}: {
|
||||
src: string
|
||||
alt?: string
|
||||
/** CSS display width in px. The image is dithered at this resolution. */
|
||||
width?: number
|
||||
class?: string
|
||||
/** Skip dithering; render the crisp source <img> instead. */
|
||||
plain?: boolean
|
||||
/** -255..255. Positive → more pixels resolve to ink (foreground). */
|
||||
bias?: number
|
||||
} = $props()
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||
let imgEl = $state<HTMLImageElement | null>(null)
|
||||
let loaded = $state(false)
|
||||
let tainted = $state(false)
|
||||
let cache: DitherCache | null = null
|
||||
|
||||
const showSkeleton = $derived(!loaded)
|
||||
const showCanvas = $derived(loaded && !plain && !tainted)
|
||||
|
||||
function readRgb(varName: string): [number, number, number] {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
|
||||
const m = raw.match(/#([0-9a-fA-F]{6})/)
|
||||
const hex = m ? m[1] : '000000'
|
||||
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
|
||||
}
|
||||
|
||||
function paint() {
|
||||
if (!cache || !canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const { bits, alpha, w, h } = cache
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
// dark source (ink) → foreground; light source (paper) → background
|
||||
const fg = readRgb('--foreground')
|
||||
const bg = readRgb('--background')
|
||||
const out = ctx.createImageData(w, h)
|
||||
const d = out.data
|
||||
for (let p = 0, i = 0; p < w * h; p++, i += 4) {
|
||||
if (alpha[p] < 64) {
|
||||
d[i + 3] = 0
|
||||
continue
|
||||
}
|
||||
const c = bits[p] ? bg : fg
|
||||
d[i] = c[0]
|
||||
d[i + 1] = c[1]
|
||||
d[i + 2] = c[2]
|
||||
d[i + 3] = 255
|
||||
}
|
||||
ctx.putImageData(out, 0, 0)
|
||||
}
|
||||
|
||||
function process(img: HTMLImageElement) {
|
||||
const nw = img.naturalWidth || img.width
|
||||
const nh = img.naturalHeight || img.height
|
||||
if (!nw || !nh) return
|
||||
const w = Math.max(1, Math.round(width))
|
||||
const h = Math.max(1, Math.round((w * nh) / nw))
|
||||
const off = document.createElement('canvas')
|
||||
off.width = w
|
||||
off.height = h
|
||||
const octx = off.getContext('2d', { willReadFrequently: true })
|
||||
if (!octx) return
|
||||
octx.drawImage(img, 0, 0, w, h)
|
||||
let data: Uint8ClampedArray
|
||||
try {
|
||||
data = octx.getImageData(0, 0, w, h).data
|
||||
} catch {
|
||||
tainted = true
|
||||
return
|
||||
}
|
||||
const n = w * h
|
||||
const lum = new Float32Array(n)
|
||||
const alpha = new Uint8Array(n)
|
||||
for (let p = 0, i = 0; p < n; p++, i += 4) {
|
||||
lum[p] = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
|
||||
alpha[p] = data[i + 3]
|
||||
}
|
||||
const thr = 128 - bias
|
||||
const bits = new Uint8Array(n)
|
||||
// Atkinson error diffusion: 6 neighbors each get 1/8 of the quantization
|
||||
// error. Softer & more "screen-printed" than Floyd–Steinberg.
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const idx = y * w + x
|
||||
const oldv = lum[idx]
|
||||
const newv = oldv < thr ? 0 : 255
|
||||
bits[idx] = newv === 255 ? 1 : 0
|
||||
const e = (oldv - newv) / 8
|
||||
if (x + 1 < w) lum[idx + 1] += e
|
||||
if (x + 2 < w) lum[idx + 2] += e
|
||||
if (y + 1 < h) {
|
||||
if (x - 1 >= 0) lum[idx + w - 1] += e
|
||||
lum[idx + w] += e
|
||||
if (x + 1 < w) lum[idx + w + 1] += e
|
||||
}
|
||||
if (y + 2 < h) lum[idx + 2 * w] += e
|
||||
}
|
||||
}
|
||||
cache = { key: src + w, bits, alpha, w, h }
|
||||
tainted = false
|
||||
paint()
|
||||
}
|
||||
|
||||
function onLoad() {
|
||||
loaded = true
|
||||
}
|
||||
|
||||
// Re-dither when the source image, target width, or plain flag changes.
|
||||
$effect(() => {
|
||||
void src
|
||||
void width
|
||||
void plain
|
||||
if (!loaded || !imgEl || plain || tainted) return
|
||||
if (imgEl.complete && imgEl.naturalWidth > 0) process(imgEl)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
// Theme flip changes --foreground/--background on <html>'s class; re-paint
|
||||
// the cached bits with the new palette (no re-dither needed).
|
||||
const mo = new MutationObserver(() => {
|
||||
if (cache && !plain) paint()
|
||||
})
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] })
|
||||
return () => mo.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="raster-wrap {className}" style="--rw:{width}px">
|
||||
{#if showSkeleton}
|
||||
<div class="raster-skeleton" aria-hidden="true"></div>
|
||||
{/if}
|
||||
{#if !plain}
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="raster-canvas"
|
||||
class:hidden={!showCanvas}
|
||||
role="img"
|
||||
aria-label={alt}
|
||||
></canvas>
|
||||
{/if}
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
{src}
|
||||
{alt}
|
||||
class="raster-fallback"
|
||||
class:hidden={showCanvas}
|
||||
onload={onLoad}
|
||||
onerror={() => {
|
||||
tainted = true
|
||||
loaded = true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.raster-wrap {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: var(--rw);
|
||||
line-height: 0;
|
||||
}
|
||||
.raster-canvas,
|
||||
.raster-fallback {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.raster-canvas.hidden,
|
||||
.raster-fallback.hidden {
|
||||
display: none;
|
||||
}
|
||||
/* No-flash placeholder while the source decodes — matches cyberspace's
|
||||
raster-image-skeleton (empty, fills with the theme background). */
|
||||
.raster-skeleton {
|
||||
width: 100%;
|
||||
min-height: calc(var(--rw) * 0.6);
|
||||
aspect-ratio: 1 / 1;
|
||||
background: var(--background);
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@
|
||||
chatErrors
|
||||
} from '$lib/stores/chat'
|
||||
import { activityLogFor } from '$lib/stores/activity'
|
||||
import { workspaceFor, startSessionWorkspace } from '$lib/stores/workspace'
|
||||
import { workspaceFor, startSessionWorkspace, taskWorking, taskFor } from '$lib/stores/workspace'
|
||||
import ChatThread from '$lib/components/ChatThread.svelte'
|
||||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
const chat = chatFor(sessionId)
|
||||
const chatMessages = chat.messages
|
||||
const chatStreaming = chat.streaming
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const chatWorking = taskWorking(sessionId)
|
||||
const chatConnectionState = chat.connectionState
|
||||
const chatError = chat.error
|
||||
const chatNotFound = chat.notFound
|
||||
@@ -40,6 +42,9 @@
|
||||
// context rail mounts.
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const workspace = workspaceFor(sessionId)
|
||||
const planSteps = workspace.planSteps
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const chatTask = taskFor(sessionId)
|
||||
const openQuestion = workspace.openQuestion
|
||||
let loading = $state(true)
|
||||
|
||||
@@ -71,7 +76,7 @@
|
||||
|
||||
// Resizable right rail — sized smaller by default since task windows open
|
||||
// narrower than the full page.
|
||||
let railSize = $state(24)
|
||||
let railSize = $state(32)
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0">
|
||||
@@ -90,19 +95,23 @@
|
||||
<ChatThread
|
||||
messages={$chatMessages}
|
||||
streaming={$chatStreaming}
|
||||
working={$chatWorking}
|
||||
connectionState={$chatConnectionState}
|
||||
error={$chatError}
|
||||
chatErrors={$chatErrors}
|
||||
activityLog={sessionActivityLog}
|
||||
{sessionId}
|
||||
question={$openQuestion}
|
||||
planSteps={$planSteps}
|
||||
taskStatus={$chatTask?.status}
|
||||
lastActiveAt={$chatTask?.last_active_at}
|
||||
onSend={(text) => sendSessionMessage(sessionId, text)}
|
||||
onCancel={() => cancelSessionStream(sessionId)}
|
||||
onReconnect={() => loadSessionChat(sessionId)}
|
||||
onDismissError={dismissError}
|
||||
/>
|
||||
</Pane>
|
||||
<Pane bind:size={railSize} minSize={18} maxSize={40}>
|
||||
<Pane bind:size={railSize} minSize={24} maxSize={60}>
|
||||
<TaskContextPanel {sessionId} />
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
|
||||
@@ -75,6 +75,18 @@
|
||||
let cw = $state(300)
|
||||
let ch = $state(300)
|
||||
|
||||
// View transform (zoom-to-fit + drag-pan). The force simulation runs in its
|
||||
// own graph coordinate space; this maps graph→screen so every entity stays
|
||||
// visible regardless of how far the layout spreads or how narrow the panel
|
||||
// is. tx/ty are screen px; scale is unitless. `userPanned` pauses auto-fit
|
||||
// once the operator drags the background, until the entity set changes or
|
||||
// they double-click to reset.
|
||||
let tx = $state(0)
|
||||
let ty = $state(0)
|
||||
let scale = $state(1)
|
||||
let userPanned = $state(false)
|
||||
const viewTransform = $derived(`translate(${tx},${ty}) scale(${scale})`)
|
||||
|
||||
function collectSlugs(value: unknown, out: Set<string>) {
|
||||
if (typeof value === 'string') {
|
||||
const m = value.match(SLUG_RE)
|
||||
@@ -206,6 +218,7 @@
|
||||
.alphaDecay(0.045)
|
||||
.on('tick', () => {
|
||||
nodes = [...nodes]
|
||||
if (!userPanned) fitView()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -261,6 +274,49 @@
|
||||
return slug.split(':').pop() ?? slug
|
||||
}
|
||||
|
||||
// Compute the view transform that fits every node (with label clearance)
|
||||
// inside the panel, clamped so a single node doesn't fill it and a huge
|
||||
// graph stays legible. No-op until the layout has positions / a size.
|
||||
function fitView() {
|
||||
if (!nodes.length || cw <= 1 || ch <= 1) return
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const n of nodes) {
|
||||
if (n.x == null || n.y == null) continue
|
||||
const r = nodeRadius(n) + 12 // node + label clearance
|
||||
minX = Math.min(minX, n.x - r)
|
||||
minY = Math.min(minY, n.y - r)
|
||||
maxX = Math.max(maxX, n.x + r)
|
||||
maxY = Math.max(maxY, n.y + r)
|
||||
}
|
||||
if (!Number.isFinite(minX)) return
|
||||
const pad = 16
|
||||
const w = Math.max(maxX - minX, 1)
|
||||
const h = Math.max(maxY - minY, 1)
|
||||
const s = Math.min((cw - pad * 2) / w, (ch - pad * 2) / h)
|
||||
const clamped = Math.max(0.2, Math.min(2.5, Number.isFinite(s) ? s : 1))
|
||||
scale = clamped
|
||||
tx = (cw - w * clamped) / 2 - minX * clamped
|
||||
ty = (ch - h * clamped) / 2 - minY * clamped
|
||||
}
|
||||
|
||||
// When the entity SET changes (a new node added/removed), re-engage auto-fit
|
||||
// so the new entity is brought into view. Same-slug re-renders (every sim
|
||||
// tick) leave the signature unchanged and don't reset.
|
||||
let lastMembership = ''
|
||||
$effect(() => {
|
||||
const sig = nodes
|
||||
.map((n) => n.slug)
|
||||
.sort()
|
||||
.join('|')
|
||||
if (sig !== lastMembership) {
|
||||
lastMembership = sig
|
||||
userPanned = false
|
||||
}
|
||||
})
|
||||
|
||||
// Live touch/health-diff lookups, keyed by slug for O(1) per-node checks
|
||||
// during render. Kept as plain objects (not Maps) since Svelte 5 runes track
|
||||
// object identity fine and this is small (≤12 touched, ≤8 diffs).
|
||||
@@ -283,16 +339,22 @@
|
||||
return typeof end === 'object' ? end.slug : end
|
||||
}
|
||||
|
||||
// ─── drag / select ───────────────────────────────────────────────────
|
||||
// ─── drag / select / pan ─────────────────────────────────────────────
|
||||
// A click (pointerdown+up with no movement in between) opens the entity
|
||||
// straight in its own floating window (WindowLayer) instead of a
|
||||
// click-through mini-panel — `selected` now only drives the highlight/dim
|
||||
// styling below, so you can see at a glance which node you last opened.
|
||||
// straight in its own floating window (WindowLayer); `selected` only drives
|
||||
// the highlight/dim styling. Node drag pins the node in GRAPH coords
|
||||
// (screen→graph via the inverse view transform). Background drag pans the
|
||||
// view and sets userPanned so auto-fit pauses. Double-click background
|
||||
// re-fits all entities.
|
||||
let dragState: { node: Node; moved: boolean } | null = null
|
||||
let panState: { x: number; y: number } | null = null
|
||||
|
||||
function toLocal(clientX: number, clientY: number) {
|
||||
function toGraph(clientX: number, clientY: number) {
|
||||
const rect = container!.getBoundingClientRect()
|
||||
return { x: clientX - rect.left, y: clientY - rect.top }
|
||||
return {
|
||||
x: (clientX - rect.left - tx) / scale,
|
||||
y: (clientY - rect.top - ty) / scale
|
||||
}
|
||||
}
|
||||
|
||||
function onNodeDown(e: PointerEvent, node: Node) {
|
||||
@@ -301,26 +363,44 @@
|
||||
dragState = { node, moved: false }
|
||||
sim?.alphaTarget(0.2).restart()
|
||||
}
|
||||
function onBgDown(e: PointerEvent) {
|
||||
panState = { x: e.clientX - tx, y: e.clientY - ty }
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
}
|
||||
function onMove(e: PointerEvent) {
|
||||
if (!dragState) return
|
||||
const p = toLocal(e.clientX, e.clientY)
|
||||
dragState.node.fx = p.x
|
||||
dragState.node.fy = p.y
|
||||
dragState.moved = true
|
||||
nodes = [...nodes]
|
||||
if (dragState) {
|
||||
const p = toGraph(e.clientX, e.clientY)
|
||||
dragState.node.fx = p.x
|
||||
dragState.node.fy = p.y
|
||||
dragState.moved = true
|
||||
nodes = [...nodes]
|
||||
return
|
||||
}
|
||||
if (panState) {
|
||||
tx = e.clientX - panState.x
|
||||
ty = e.clientY - panState.y
|
||||
userPanned = true
|
||||
}
|
||||
}
|
||||
function selectAndOpen(node: Node) {
|
||||
selected = node
|
||||
openEntityWindow(node.slug)
|
||||
}
|
||||
function onUp() {
|
||||
if (!dragState) return
|
||||
const { node, moved } = dragState
|
||||
node.fx = null
|
||||
node.fy = null
|
||||
sim?.alphaTarget(0)
|
||||
dragState = null
|
||||
if (!moved) selectAndOpen(node)
|
||||
if (dragState) {
|
||||
const { node, moved } = dragState
|
||||
node.fx = null
|
||||
node.fy = null
|
||||
sim?.alphaTarget(0)
|
||||
dragState = null
|
||||
if (!moved) selectAndOpen(node)
|
||||
return
|
||||
}
|
||||
panState = null
|
||||
}
|
||||
function refit() {
|
||||
userPanned = false
|
||||
fitView()
|
||||
}
|
||||
|
||||
const selectedRelations = $derived(
|
||||
@@ -342,7 +422,7 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
<aside class="flex h-full min-h-0 flex-col bg-card/40">
|
||||
<aside class="flex h-full min-h-0 flex-col bg-card">
|
||||
{#if nowTouching}
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary"
|
||||
@@ -447,9 +527,11 @@
|
||||
class="h-full w-full touch-none select-none"
|
||||
role="application"
|
||||
aria-label="Session entity graph"
|
||||
onpointerdown={onBgDown}
|
||||
onpointermove={onMove}
|
||||
onpointerup={onUp}
|
||||
onpointercancel={onUp}
|
||||
ondblclick={refit}
|
||||
>
|
||||
<defs>
|
||||
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
@@ -457,8 +539,9 @@
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width={cw} height={ch} fill="url(#{dotGridId})" />
|
||||
<g>
|
||||
{#each links as link}
|
||||
<g transform={viewTransform}>
|
||||
<g>
|
||||
{#each links as link}
|
||||
{@const s = endpoint(link.source)}
|
||||
{@const t = endpoint(link.target)}
|
||||
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
|
||||
@@ -560,6 +643,7 @@
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,170 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import {
|
||||
startWorkspace,
|
||||
planSteps,
|
||||
currentTask,
|
||||
touched,
|
||||
healthDiffs,
|
||||
workspaceFor,
|
||||
taskFor
|
||||
taskFor,
|
||||
taskWorking,
|
||||
currentWorking,
|
||||
currentTask
|
||||
} from '$lib/stores/workspace'
|
||||
import { streaming, messages, chatFor } from '$lib/stores/chat'
|
||||
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
||||
import { messages, chatFor, streaming, connectionState } from '$lib/stores/chat'
|
||||
import SessionGraph from './SessionGraph.svelte'
|
||||
import UnifiedTimeline from './UnifiedTimeline.svelte'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import Spinner from './Spinner.svelte'
|
||||
|
||||
// Omitted (main Chat page): tracks the global "current session" — one
|
||||
// shared view, same as always. Passed (a floating task window's
|
||||
// SessionChatWindow): this panel switches entirely to that session's own
|
||||
// store bundle (workspaceFor/chatFor/activityLogFor), so several windows'
|
||||
// panels can be open and live at once instead of all showing whatever
|
||||
// happens to be the single global "current session". Per-session workspace
|
||||
// tracking (startSessionWorkspace) is started by SessionChatWindow itself,
|
||||
// not here — it has to run even while this panel stays unmounted (see its
|
||||
// hasContext gate), so only the global fallback path starts its own here.
|
||||
let { sessionId = null }: { sessionId?: string | null } = $props()
|
||||
|
||||
onMount(() => (sessionId ? undefined : startWorkspace()))
|
||||
|
||||
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
|
||||
const planStepsStore = $derived(ws ? ws.planSteps : planSteps)
|
||||
const touchedStore = $derived(ws ? ws.touched : touched)
|
||||
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
|
||||
const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask)
|
||||
const chat = $derived(sessionId ? chatFor(sessionId) : null)
|
||||
const streamingStore = $derived(chat ? chat.streaming : streaming)
|
||||
const messagesStore = $derived(chat ? chat.messages : messages)
|
||||
const activityLogStore = $derived(sessionId ? activityLogFor(sessionId) : activityLog)
|
||||
|
||||
let scopeOpen = $state(true)
|
||||
let activityOpen = $state(true)
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const taskWorkingStore = sessionId ? taskWorking(sessionId) : currentWorking
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const taskStreamingStore = sessionId ? chatFor(sessionId).streaming : streaming
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const taskConnStore = sessionId ? chatFor(sessionId).connectionState : connectionState
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const taskStatusStore = sessionId ? taskFor(sessionId) : currentTask
|
||||
|
||||
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are
|
||||
// percentages of the panel's height; undefined means "share the space
|
||||
// evenly with the other auto sections". Collapsing a section pins it to
|
||||
// COLLAPSED_SIZE (roughly a header's worth of height) and remembers its
|
||||
// last size so reopening restores it.
|
||||
const COLLAPSED_SIZE = 6
|
||||
const OPEN_MIN_SIZE = 12
|
||||
let sizes = $state<(number | undefined)[]>([30, 70])
|
||||
// Reopening must restore a concrete number, never `undefined` — the pane
|
||||
// only re-triggers the library's resize/equalize pass when `size` changes
|
||||
// to a different *number*, so setting it back to `undefined` silently
|
||||
// no-ops and leaves the section stuck at its collapsed height.
|
||||
let savedSizes: number[] = [30, 70]
|
||||
// ── stuck detection (mirrors ChatThread) ───────────────────────────────
|
||||
let now = $state(Date.now())
|
||||
$effect(() => {
|
||||
if (!$taskWorkingStore) return
|
||||
const id = setInterval(() => {
|
||||
now = Date.now()
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
})
|
||||
const lastActiveAt = $derived($taskStatusStore?.last_active_at)
|
||||
const isStuck = $derived(
|
||||
$taskWorkingStore &&
|
||||
!$taskStreamingStore &&
|
||||
lastActiveAt &&
|
||||
now - new Date(lastActiveAt).getTime() > 300_000
|
||||
)
|
||||
|
||||
function toggleSection(i: number, isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
savedSizes[i] = sizes[i] ?? savedSizes[i]
|
||||
sizes[i] = COLLAPSED_SIZE
|
||||
} else {
|
||||
sizes[i] = savedSizes[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Plan collapsed status
|
||||
const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
|
||||
const planTotal = $derived($planStepsStore.length)
|
||||
|
||||
// Activity collapsed status
|
||||
const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
||||
<!-- Scope -->
|
||||
<Pane
|
||||
bind:size={sizes[0]}
|
||||
minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE}
|
||||
maxSize={scopeOpen ? 100 : COLLAPSED_SIZE}
|
||||
class="flex flex-col"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => {
|
||||
toggleSection(0, scopeOpen)
|
||||
scopeOpen = !scopeOpen
|
||||
}}
|
||||
>
|
||||
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
||||
class="size-3"
|
||||
/>{/if}
|
||||
<span>Scope</span>
|
||||
{#if !scopeOpen}
|
||||
<span class="ml-auto font-normal normal-case"
|
||||
>{$touchedStore.length
|
||||
? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}`
|
||||
: 'Graph'}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{#if scopeOpen}
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionGraph
|
||||
messages={$messagesStore}
|
||||
touched={$touchedStore}
|
||||
healthDiffs={$healthDiffsStore}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
|
||||
<!-- Activity (merged plan + event log) -->
|
||||
<Pane
|
||||
bind:size={sizes[1]}
|
||||
minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE}
|
||||
maxSize={activityOpen ? 100 : COLLAPSED_SIZE}
|
||||
class="flex flex-col"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => {
|
||||
toggleSection(1, activityOpen)
|
||||
activityOpen = !activityOpen
|
||||
}}
|
||||
>
|
||||
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
||||
class="size-3"
|
||||
/>{/if}
|
||||
<span>Activity</span>
|
||||
{#if $streamingStore && activityRunning > 0}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{/if}
|
||||
{#if planTotal > 0}
|
||||
<span
|
||||
class="font-normal normal-case tabular-nums {planDone === planTotal
|
||||
? 'text-muted-foreground'
|
||||
: 'text-primary'}">{planDone}/{planTotal}</span
|
||||
>
|
||||
{/if}
|
||||
{#if !activityOpen && planTotal === 0}
|
||||
{#if $taskStore?.goal}
|
||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case"
|
||||
>{$taskStore.goal}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case text-muted-foreground"
|
||||
>No activity yet</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if activityOpen}
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<UnifiedTimeline
|
||||
entries={$activityLogStore}
|
||||
planSteps={$planStepsStore}
|
||||
streaming={$streamingStore}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
<div class="shrink-0" style="aspect-ratio: 1; width: 100%;">
|
||||
<SessionGraph
|
||||
messages={$messagesStore}
|
||||
touched={$touchedStore}
|
||||
healthDiffs={$healthDiffsStore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
106
web/src/lib/components/ThinkingBlock.svelte
Normal file
106
web/src/lib/components/ThinkingBlock.svelte
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { Brain, ChevronRight } from '@lucide/svelte'
|
||||
let { thinking }: { thinking: string } = $props()
|
||||
let expanded = $state(false)
|
||||
</script>
|
||||
|
||||
<div class="thinking-block">
|
||||
<button
|
||||
class="row"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Brain class="icon size-3" />
|
||||
<span class="text min-w-0 flex-1">
|
||||
<span class="label">Thought{thinking.includes('\n') ? 's' : ''}</span>
|
||||
</span>
|
||||
<span class="summary">{thinking.slice(0, 60).replace(/\n/g, ' ')}{thinking.length > 60 ? '…' : ''}</span>
|
||||
<ChevronRight class="chev size-3 {expanded ? 'open' : ''}" />
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="detail"><pre>{thinking}</pre></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thinking-block {
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: 0.5rem;
|
||||
animation: thinking-in 0.15s ease-out;
|
||||
}
|
||||
@keyframes thinking-in {
|
||||
from { opacity: 0; transform: translateY(-2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.thinking-block { animation: none; }
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.2rem 0;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:hover .label {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.summary {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 45%;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.chev {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.6;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.chev.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.detail {
|
||||
padding: 0.25rem 0 0.4rem 1.25rem;
|
||||
}
|
||||
.thinking-block :global(pre) {
|
||||
margin: 0;
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--foreground);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chev { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,135 +0,0 @@
|
||||
<script lang="ts">
|
||||
// One tool call inside AgentTrace's expanded list. Renders as a borderless
|
||||
// row (the trace supplies the container/border) whose own click reveals the
|
||||
// raw args/result — so the trace stays a readable thinking log by default
|
||||
// and the JSON is one more click away, not stacked inline.
|
||||
import { Check, ChevronRight, Loader2, X } from '@lucide/svelte'
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
import { toolActivityLabel } from '$lib/stores/activity'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
let expanded = $state(false)
|
||||
let liveEl = $state<HTMLPreElement | null>(null)
|
||||
|
||||
const status = $derived.by(() => {
|
||||
if (tool.type === 'tool_use') return 'running'
|
||||
if (tool.error) return 'error'
|
||||
return 'done'
|
||||
})
|
||||
|
||||
// Auto-open while a command is streaming its output, so the operator sees it
|
||||
// without an extra click — mirrors UnifiedTimeline. Once the tool_result
|
||||
// lands (status flips off running) liveOutput clears and the card respects
|
||||
// the manual toggle again. (F4)
|
||||
const open = $derived(expanded || !!tool.liveOutput)
|
||||
$effect(() => {
|
||||
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
|
||||
})
|
||||
|
||||
const label = $derived(toolActivityLabel(tool))
|
||||
|
||||
const argsSummary = $derived.by(() => {
|
||||
if (!tool.args) return ''
|
||||
const entries = Object.entries(tool.args)
|
||||
if (entries.length === 0) return ''
|
||||
const first = entries[0]
|
||||
const val = typeof first[1] === 'string' ? first[1] : JSON.stringify(first[1])
|
||||
return `${first[0]}: ${val.length > 60 ? val.slice(0, 60) + '…' : val}`
|
||||
})
|
||||
|
||||
const hasDetail = $derived(
|
||||
!!tool.args || (tool.result !== undefined && tool.result !== null) || !!tool.error
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="tool-row">
|
||||
<button
|
||||
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/40 disabled:cursor-default"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={open}
|
||||
disabled={!hasDetail && !tool.liveOutput}
|
||||
>
|
||||
<span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
|
||||
{#if status === 'running'}
|
||||
<Loader2 class="size-3 animate-spin" />
|
||||
{:else if status === 'error'}
|
||||
<X class="size-3" />
|
||||
{:else}
|
||||
<Check class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-xs text-foreground/90">{label}</span>
|
||||
{#if argsSummary}
|
||||
<span class="block truncate font-mono text-[10px] text-muted-foreground/60"
|
||||
>{argsSummary}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-[10px] text-muted-foreground/50">{tool.name}</span>
|
||||
{#if hasDetail || tool.liveOutput}
|
||||
<ChevronRight
|
||||
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {open
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="space-y-2 px-2 pb-2 pl-7">
|
||||
{#if tool.liveOutput}
|
||||
<div>
|
||||
<div
|
||||
class="mb-1 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-primary"
|
||||
>
|
||||
<Loader2 class="size-2.5 animate-spin" />
|
||||
Live output
|
||||
</div>
|
||||
<pre
|
||||
bind:this={liveEl}
|
||||
class="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted/60 p-2 font-mono text-[11px] text-foreground/90">{tool.liveOutput}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tool.args}
|
||||
<div>
|
||||
<div
|
||||
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Args
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
|
||||
tool.args,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tool.result !== undefined && tool.result !== null}
|
||||
<div>
|
||||
<div
|
||||
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Result
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
|
||||
tool.result,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tool.error}
|
||||
<div>
|
||||
<div class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-destructive">
|
||||
Error
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-48 overflow-x-auto rounded-md border border-destructive/20 bg-destructive/5 p-2 text-[11px] text-destructive">{tool.error}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
258
web/src/lib/components/ToolLine.svelte
Normal file
258
web/src/lib/components/ToolLine.svelte
Normal file
@@ -0,0 +1,258 @@
|
||||
<script lang="ts" module>
|
||||
// One tool call rendered as a compact, progressive line — the Claude-Code
|
||||
// signature for the inline trace. Collapsed: state icon + humanized label +
|
||||
// a one-line RESULT summary on completion (or a "live" tag while a `run`
|
||||
// streams). Expanded (click): raw args/result/error in opaque <pre> blocks.
|
||||
// Border-driven, square, no rounded/shadow (cyberspace system).
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Check, ChevronRight, Loader2, X } from '@lucide/svelte'
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
import { toolActivityLabel, toolResultSummary } from '$lib/stores/activity'
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
let expanded = $state(false)
|
||||
let liveEl = $state<HTMLPreElement | null>(null)
|
||||
|
||||
const status = $derived(tool.type === 'tool_use' ? 'running' : tool.error ? 'error' : 'done')
|
||||
const label = $derived(toolActivityLabel(tool))
|
||||
const summary = $derived(toolResultSummary(tool))
|
||||
// Tool calls start COLLAPSED — the operator expands them on demand. The
|
||||
// live `run` output is shown in a separate pinned-tail mini pane below the
|
||||
// collapsed row (not by auto-opening the whole detail), so the line stays
|
||||
// compact while the command streams. Previously `open` auto-expanded on
|
||||
// liveOutput and then collapsed when it cleared — the "start open, then
|
||||
// collapse" behavior the operator found confusing.
|
||||
const open = $derived(expanded)
|
||||
$effect(() => {
|
||||
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
|
||||
})
|
||||
|
||||
const hasDetail = $derived(
|
||||
!!tool.args || (tool.result !== undefined && tool.result !== null) || !!tool.error
|
||||
)
|
||||
function pretty(v: unknown): string {
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(v), null, 2)
|
||||
} catch {
|
||||
return v
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(v, null, 2)
|
||||
} catch {
|
||||
return String(v)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tool-line">
|
||||
<button
|
||||
class="row"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={open}
|
||||
disabled={!hasDetail && !tool.liveOutput}
|
||||
>
|
||||
<span class="icon {status}" aria-hidden="true">
|
||||
{#if status === 'running'}
|
||||
<Loader2 class="size-3 animate-spin" />
|
||||
{:else if status === 'error'}
|
||||
<X class="size-3" />
|
||||
{:else}
|
||||
<Check class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text min-w-0 flex-1">
|
||||
<span class="label {status === 'done' ? 'done-text' : ''}">{label}</span>
|
||||
</span>
|
||||
{#if status === 'running' && tool.liveOutput}
|
||||
<span class="live-tag"><Loader2 class="size-2.5 animate-spin" /> live</span>
|
||||
{:else if status === 'done' && summary}
|
||||
<span class="summary">{summary}</span>
|
||||
{:else if status === 'error'}
|
||||
<span class="summary err">error</span>
|
||||
{/if}
|
||||
{#if hasDetail || tool.liveOutput}
|
||||
<ChevronRight class="chev size-3 {open ? 'open' : ''}" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if tool.liveOutput}
|
||||
<!-- Live `run` output — pinned-tail mini pane, always visible while the
|
||||
command streams. Separate from the expand/collapse state so the tool
|
||||
line itself stays collapsed. -->
|
||||
<div class="live-output">
|
||||
<pre bind:this={liveEl} class="live">{tool.liveOutput}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if open}
|
||||
<div class="detail">
|
||||
{#if tool.args}
|
||||
<div class="khead">Args</div>
|
||||
<pre>{pretty(tool.args)}</pre>
|
||||
{/if}
|
||||
{#if tool.result !== undefined && tool.result !== null}
|
||||
<div class="khead">Result</div>
|
||||
<pre class={status === 'error' ? 'err' : ''}>{pretty(tool.result)}</pre>
|
||||
{/if}
|
||||
{#if tool.error}
|
||||
<div class="khead err">Error</div>
|
||||
<pre class="err">{tool.error}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tool-line {
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: 0.5rem;
|
||||
animation: tool-line-in 0.15s ease-out;
|
||||
}
|
||||
@keyframes tool-line-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tool-line {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.2rem 0;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.row:not(:disabled):hover .label {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon.running {
|
||||
color: var(--primary);
|
||||
}
|
||||
.icon.error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
.icon.done {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.label.done-text {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.summary {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 45%;
|
||||
}
|
||||
.summary.err {
|
||||
color: var(--destructive);
|
||||
}
|
||||
.live-tag {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--primary);
|
||||
}
|
||||
.chev {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.6;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.chev.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0 0.4rem 1.25rem;
|
||||
}
|
||||
.live-output {
|
||||
padding: 0.1rem 0 0.3rem 1.25rem;
|
||||
}
|
||||
.live-output :global(pre.live) {
|
||||
max-height: 8rem;
|
||||
}
|
||||
.khead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.khead.err {
|
||||
color: var(--destructive);
|
||||
}
|
||||
.tool-line :global(pre) {
|
||||
margin: 0;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.tool-line :global(pre.err) {
|
||||
color: var(--destructive);
|
||||
border-color: color-mix(in oklab, var(--destructive) 40%, var(--border));
|
||||
background: color-mix(in oklab, var(--destructive) 6%, var(--muted));
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chev {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
325
web/src/lib/components/TurnTrace.svelte
Normal file
325
web/src/lib/components/TurnTrace.svelte
Normal file
@@ -0,0 +1,325 @@
|
||||
<script lang="ts" module>
|
||||
// The agent's working trace for ONE assistant turn, rendered inline as a
|
||||
// progressive Claude-Code-style stream instead of a collapsed blob (replaces
|
||||
// AgentTrace). Top to bottom: live plan checklist (running turn only), a
|
||||
// "Thinking…" line while the model reasons (before the first tool / between
|
||||
// steps), then each tool call as its own compact line grouped under its plan
|
||||
// step. The streamed text answer is rendered by ChatThread after this.
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
import type { PlanStep } from '$lib/api'
|
||||
import ToolLine from './ToolLine.svelte'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import PauseIcon from '@lucide/svelte/icons/pause'
|
||||
import SlashIcon from '@lucide/svelte/icons/slash'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
|
||||
let {
|
||||
tools = [],
|
||||
status = 'idle',
|
||||
label = null,
|
||||
isLast = false,
|
||||
planSteps = [],
|
||||
taskStatus
|
||||
}: {
|
||||
tools?: ToolCallResult[]
|
||||
/** `idle` = this turn has no live state (a finished historical turn). */
|
||||
status?: 'running' | 'done' | 'error' | 'idle'
|
||||
/** Live indicator text while thinking (the running step / tool / "thinking…"). */
|
||||
label?: string | null
|
||||
isLast?: boolean
|
||||
planSteps?: PlanStep[]
|
||||
taskStatus?: string
|
||||
} = $props()
|
||||
|
||||
const TERMINAL = new Set(['done', 'failed', 'abandoned'])
|
||||
|
||||
// seq → step title (current-gen only) so tool groups can label themselves.
|
||||
const stepTitle = $derived(new Map<number, string>(planSteps.map((s) => [s.seq, s.title])))
|
||||
|
||||
// Group consecutive tools by their plan step (when attributed). Plan-less /
|
||||
// meta tools (propose_plan, set_goal, …) have no stepSeq and form orphan
|
||||
// groups rendered without a header.
|
||||
interface Group {
|
||||
step: { seq: number; title: string } | null
|
||||
tools: ToolCallResult[]
|
||||
}
|
||||
const groups = $derived.by<Group[]>(() => {
|
||||
const out: Group[] = []
|
||||
let cur: Group | null = null
|
||||
for (const t of tools) {
|
||||
const seq = t.stepSeq
|
||||
if (!cur || (cur.step?.seq ?? null) !== (seq ?? null)) {
|
||||
cur = {
|
||||
step: seq != null && stepTitle.has(seq) ? { seq, title: stepTitle.get(seq)! } : null,
|
||||
tools: []
|
||||
}
|
||||
out.push(cur)
|
||||
}
|
||||
cur.tools.push(t)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// Thinking line: visible while the turn is running and the model is reasoning
|
||||
// — before the first tool, or after a tool finishes but before the next one
|
||||
// starts. Hidden while a tool is mid-flight (its own spinner carries the
|
||||
// liveness) and on idle/finished turns.
|
||||
const lastToolRunning = $derived(
|
||||
tools.length > 0 && tools[tools.length - 1].type === 'tool_use'
|
||||
)
|
||||
const showThinking = $derived(status === 'running' && !lastToolRunning)
|
||||
|
||||
// Plan checklist: only on the running/last turn, and only if a plan exists.
|
||||
const showPlan = $derived(isLast && planSteps.length > 0)
|
||||
const planTerminal = $derived(!!taskStatus && TERMINAL.has(taskStatus))
|
||||
let planExpanded = $state(false)
|
||||
const planDone = $derived(planSteps.filter((s) => s.status === 'done').length)
|
||||
const planFailedStep = $derived(planSteps.find((s) => s.status === 'failed'))
|
||||
|
||||
// Elapsed time on the running step — ticks every second while a step is
|
||||
// running so the operator can see how long it's been going (and spot a
|
||||
// stuck step).
|
||||
let now = $state(Date.now())
|
||||
$effect(() => {
|
||||
const running = planSteps.some((s) => s.status === 'running')
|
||||
if (!running) return
|
||||
const id = setInterval(() => {
|
||||
now = Date.now()
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
})
|
||||
function stepElapsed(s: PlanStep): string {
|
||||
if (s.status !== 'running' || !s.started_at) return ''
|
||||
const sec = Math.max(0, Math.floor((now - new Date(s.started_at).getTime()) / 1000))
|
||||
if (sec < 60) return `${sec}s`
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m`
|
||||
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showPlan}
|
||||
<div class="plan {planTerminal && !planExpanded ? 'plan-collapsed' : ''}">
|
||||
{#if planTerminal && !planExpanded}
|
||||
<button class="plan-summary" onclick={() => (planExpanded = true)}>
|
||||
{#if planFailedStep}
|
||||
<XIcon class="size-3 text-destructive" />
|
||||
<span class="plan-summary-text">Plan failed — step {planFailedStep.seq}</span>
|
||||
{:else}
|
||||
<CheckIcon class="size-3 text-primary" />
|
||||
<span class="plan-summary-text">Plan complete — {planDone}/{planSteps.length} steps</span>
|
||||
{/if}
|
||||
<ChevronRightIcon class="size-3 text-muted-foreground/60" />
|
||||
</button>
|
||||
{:else}
|
||||
<div class="plan-head">
|
||||
<span class="plan-head-label">Plan</span>
|
||||
<span class="plan-head-count">{planDone}/{planSteps.length}</span>
|
||||
</div>
|
||||
<ul class="plan-list">
|
||||
{#each planSteps as s (s.id)}
|
||||
<li class="plan-step {s.status === 'running' ? 'running' : ''}">
|
||||
<span class="plan-node {s.status}" aria-hidden="true">
|
||||
{#if s.status === 'running'}<Spinner class="size-3 text-primary" />
|
||||
{:else if s.status === 'done'}<CheckIcon class="size-2.5" strokeWidth={3.5} />
|
||||
{:else if s.status === 'failed'}<XIcon class="size-2.5" strokeWidth={3.5} />
|
||||
{:else if s.status === 'blocked'}<PauseIcon class="size-2" strokeWidth={3} />
|
||||
{:else if s.status === 'skipped' || s.status === 'replaced'}<SlashIcon class="size-2" strokeWidth={3} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="plan-title" title={s.title}>{s.title}</span>
|
||||
{#if s.status === 'running'}
|
||||
<span class="plan-elapsed">{stepElapsed(s)}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Thinking line: always rendered (fixed height) so appearing/disappearing
|
||||
doesn't shift the layout — it just fades in/out. Shows a STABLE label
|
||||
("Working…") rather than the current operation, which would rewrite
|
||||
itself on every step/tool transition and read as text appearing and
|
||||
disappearing. The current operation is already visible in the plan
|
||||
checklist (running step) and the tool lines below. -->
|
||||
<div class="thinking {showThinking ? '' : 'thinking-hidden'}" aria-hidden={!showThinking}>
|
||||
<Spinner class="size-3 shrink-0 text-primary" />
|
||||
<span class="thinking-text">Working…</span>
|
||||
</div>
|
||||
|
||||
{#if groups.length > 0}
|
||||
<div class="tools">
|
||||
{#each groups as g, gi (gi)}
|
||||
{#if g.step}
|
||||
<div class="step-head">Step {g.step.seq} · {g.step.title}</div>
|
||||
{/if}
|
||||
{#each g.tools as tool (tool.id ?? `${gi}-${tool.name}`)}
|
||||
<ToolLine {tool} />
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if status === 'idle' && tools.length === 0}
|
||||
<!-- finished turn with no tools: nothing to render -->
|
||||
{:else if status === 'error'}
|
||||
<div class="thinking err">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||
<span class="thinking-text">{label || 'Turn ended with an error'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.plan {
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in oklab, var(--primary) 3%, var(--card));
|
||||
padding: 0.35rem 0.55rem 0.4rem;
|
||||
margin-bottom: 0.35rem;
|
||||
animation: plan-in 0.2s ease-out;
|
||||
}
|
||||
@keyframes plan-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.plan {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.plan-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.plan-summary-text {
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
flex: 1;
|
||||
}
|
||||
.plan-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.plan-head-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.plan-head-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--primary);
|
||||
}
|
||||
.plan-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.plan-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
.plan-step.running {
|
||||
background: color-mix(in oklab, var(--primary) 8%, transparent);
|
||||
margin: 0 -0.3rem;
|
||||
padding-left: 0.3rem;
|
||||
padding-right: 0.3rem;
|
||||
}
|
||||
.plan-node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.plan-node.done {
|
||||
color: var(--primary);
|
||||
}
|
||||
.plan-node.failed {
|
||||
color: var(--destructive);
|
||||
}
|
||||
.plan-node.running {
|
||||
color: var(--primary);
|
||||
}
|
||||
.plan-title {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.plan-step.running .plan-title {
|
||||
color: var(--foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
.plan-elapsed {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--primary);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.2rem 0;
|
||||
height: 1.5rem;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.thinking-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.thinking.err {
|
||||
color: var(--destructive);
|
||||
}
|
||||
.thinking-text {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.step-head {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted-foreground);
|
||||
padding: 0.35rem 0 0.1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,585 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import type { ActivityEntry } from '$lib/stores/activity'
|
||||
import type { PlanStep } from '$lib/api'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import PauseIcon from '@lucide/svelte/icons/pause'
|
||||
import SlashIcon from '@lucide/svelte/icons/slash'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
|
||||
import FlagIcon from '@lucide/svelte/icons/flag'
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
|
||||
import { openEntityWindow } from '$lib/stores/windows'
|
||||
|
||||
// Merged plan + activity timeline, designed for the narrow rail:
|
||||
// - ordered newest-first: what the agent is doing right now is at the top,
|
||||
// history flows downward, and the goal sits at the bottom where the task
|
||||
// began (see the sort in `items`)
|
||||
// - one continuous vertical "backbone"; every item owns a segment of it,
|
||||
// colored by state (done = filled primary, running = faint primary,
|
||||
// pending/future = muted) so the line visibly fills in as work completes
|
||||
// - plan steps are filled status nodes ON the backbone; their tool calls
|
||||
// branch off with horizontal stubs
|
||||
// - flat entries (goal/knowledge/complete/orphan tools) are milestone
|
||||
// markers on the same backbone
|
||||
// - the running step auto-expands and the view auto-scrolls to keep the
|
||||
// current step visible while the agent works (follow mode disengages if
|
||||
// the operator scrolls down into history, re-engages when streaming
|
||||
// starts again)
|
||||
let {
|
||||
entries,
|
||||
planSteps: steps,
|
||||
streaming = false
|
||||
}: {
|
||||
entries: ActivityEntry[]
|
||||
planSteps: PlanStep[]
|
||||
streaming?: boolean
|
||||
} = $props()
|
||||
|
||||
// Explicit user toggles only — default open state derives from step status
|
||||
// (running = expanded, everything else = collapsed) so a step collapses
|
||||
// itself the moment it finishes unless the operator pinned it open.
|
||||
let stepToggles = $state(new Map<string, boolean>())
|
||||
let expandedTools = $state(new Set<string>())
|
||||
|
||||
function stepOpen(step: PlanStep): boolean {
|
||||
return stepToggles.get(step.id) ?? step.status === 'running'
|
||||
}
|
||||
function toggleStep(step: PlanStep) {
|
||||
stepToggles.set(step.id, !stepOpen(step))
|
||||
stepToggles = new Map(stepToggles)
|
||||
}
|
||||
// Pin each streaming output pane to its tail as chunks arrive. Keyed by
|
||||
// tool id because several run entries can be on screen, though only the
|
||||
// newest one is ever actually streaming.
|
||||
let liveOutputEls = $state<Record<string, HTMLPreElement | null>>({})
|
||||
$effect(() => {
|
||||
// Depend on entries only. liveOutputEls is written by bind:this, so
|
||||
// tracking it here would let a re-render re-trigger this effect.
|
||||
const current = entries
|
||||
untrack(() => {
|
||||
for (const e of current) {
|
||||
if (!e.liveOutput) continue
|
||||
const el = liveOutputEls[e.id]
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function toggleTool(id: string) {
|
||||
if (expandedTools.has(id)) expandedTools.delete(id)
|
||||
else expandedTools.add(id)
|
||||
expandedTools = new Set(expandedTools)
|
||||
}
|
||||
|
||||
// ── Timeline model ────────────────────────────────────────────────────────
|
||||
type TLItem =
|
||||
| { kind: 'step'; step: PlanStep; tools: ActivityEntry[]; ts: number }
|
||||
| { kind: 'entry'; entry: ActivityEntry; ts: number }
|
||||
|
||||
const items = $derived.by<TLItem[]>(() => {
|
||||
const stepIds = new Set(steps.map((s) => s.id))
|
||||
const out: TLItem[] = []
|
||||
|
||||
for (const s of steps) {
|
||||
if (s.status === 'pending' && !entries.some((e) => e.stepSeq === s.seq)) {
|
||||
// Pending steps with no activity yet still show on the timeline so
|
||||
// the operator sees what's coming — but only if a plan exists. ts 0
|
||||
// parks them at the tail of the newest-first sort below (see there).
|
||||
if (steps.length > 0) {
|
||||
out.push({ kind: 'step', step: s, tools: [], ts: 0 })
|
||||
}
|
||||
continue
|
||||
}
|
||||
const tools = entries.filter(
|
||||
(e) =>
|
||||
e.stepSeq === s.seq &&
|
||||
(e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error')
|
||||
)
|
||||
const stepEntry = entries.find((e) => e.id === s.id)
|
||||
// Timed from the step's own entry, else its earliest tool — so a step
|
||||
// is placed by when it started, not by its latest activity.
|
||||
const ts = stepEntry?.timestamp ?? tools[0]?.timestamp ?? Date.now()
|
||||
// Tools inside a step run newest-first too, matching the outer order.
|
||||
out.push({ kind: 'step', step: s, tools: [...tools].reverse(), ts })
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const isTool = e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error'
|
||||
if (isTool && e.stepSeq != null) continue // nested under its step
|
||||
if (!isTool && stepIds.has(e.id)) continue // rendered as step node
|
||||
out.push({ kind: 'entry', entry: e, ts: e.timestamp })
|
||||
}
|
||||
|
||||
// Newest first: whatever the agent is doing right now sits at the top of
|
||||
// the rail, with history flowing downward. The two ts-0 groups fall to
|
||||
// the bottom for free, which is where both belong in this order: the goal
|
||||
// (timestamp 0 — where the task started) and not-yet-run plan steps.
|
||||
// Sorting the latter by their future position would put them *above* the
|
||||
// running step and push it off the top, which is exactly what this
|
||||
// ordering exists to prevent. Array.sort is stable, so each group keeps
|
||||
// its insertion order (plan steps in seq order).
|
||||
out.sort((a, b) => b.ts - a.ts)
|
||||
return out
|
||||
})
|
||||
|
||||
// ── Current activity + auto-scroll ────────────────────────────────────────
|
||||
const currentId = $derived.by<string | null>(() => {
|
||||
const runningTool = entries.find((e) => e.type === 'tool_running' && e.status === 'running')
|
||||
if (runningTool) return runningTool.id
|
||||
const runningStep = steps.find((s) => s.status === 'running')
|
||||
if (runningStep) return runningStep.id
|
||||
return null
|
||||
})
|
||||
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
let follow = $state(true)
|
||||
|
||||
// Newest-first, so "following the agent" means being parked at the top —
|
||||
// the mirror of the bottom-anchored follow this had when it ran oldest-first.
|
||||
function onScroll() {
|
||||
if (!container) return
|
||||
follow = container.scrollTop < 80
|
||||
}
|
||||
|
||||
// A new turn re-engages follow mode even if the operator had scrolled up.
|
||||
let wasStreaming = $state(false)
|
||||
$effect(() => {
|
||||
if (streaming && !wasStreaming) follow = true
|
||||
wasStreaming = streaming
|
||||
})
|
||||
|
||||
// Scroll to the current step/tool whenever it changes (smooth) or when new
|
||||
// entries land while following (instant, to avoid scroll-queue jank).
|
||||
$effect(() => {
|
||||
if (!currentId || !follow || !container) return
|
||||
container
|
||||
.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
let lastEntryCount = 0
|
||||
$effect(() => {
|
||||
const n = entries.length
|
||||
if (n === lastEntryCount) return
|
||||
lastEntryCount = n
|
||||
if (!follow || !container) return
|
||||
const target = currentId
|
||||
? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
||||
: null
|
||||
if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' })
|
||||
else container.scrollTop = 0
|
||||
})
|
||||
|
||||
// ── Presentation helpers ──────────────────────────────────────────────────
|
||||
// Segment geometry: the backbone's center runs at x=17.5px (node center:
|
||||
// px-3 = 11.25px at the app's 15px root font-size + half of the 13px node),
|
||||
// so the 1px line sits at left-17px. Each item's segment spans its full
|
||||
// height so tools inside an expanded step stay on the line; first/last
|
||||
// items clip theirs to their node/tool centers so the line never dangles
|
||||
// past the timeline's ends.
|
||||
function segClass(
|
||||
status: string,
|
||||
isFirst: boolean,
|
||||
isLast: boolean,
|
||||
expandedWithTools: boolean
|
||||
): string {
|
||||
let color = 'bg-border'
|
||||
if (status === 'done') color = 'bg-primary/60'
|
||||
else if (status === 'running') color = 'bg-primary/40'
|
||||
else if (status === 'failed') color = 'bg-destructive/40'
|
||||
|
||||
if (isFirst && isLast) return `${color} top-[13px] h-0`
|
||||
if (isFirst) return `${color} top-[13px] bottom-0`
|
||||
if (isLast && expandedWithTools) return `${color} top-0 bottom-[11px]`
|
||||
if (isLast) return `${color} top-0 bottom-[calc(100%-13px)]`
|
||||
return `${color} top-0 bottom-0`
|
||||
}
|
||||
|
||||
function entryIcon(entry: ActivityEntry) {
|
||||
switch (entry.type) {
|
||||
case 'goal':
|
||||
return MilestoneIcon
|
||||
case 'knowledge':
|
||||
return SparklesIcon
|
||||
case 'complete':
|
||||
return FlagIcon
|
||||
case 'question':
|
||||
return HelpCircleIcon
|
||||
default:
|
||||
return WrenchIcon
|
||||
}
|
||||
}
|
||||
function hhmm(ts: number): string {
|
||||
if (!ts || ts > Number.MAX_SAFE_INTEGER - 1000) return ''
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
function hhmmss(ts: number): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
function prettyPrint(raw: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||
{#if items.length === 0}
|
||||
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
|
||||
<svg viewBox="0 0 64 110" class="h-14 w-auto text-muted-foreground/40" fill="none">
|
||||
<line
|
||||
x1="32"
|
||||
y1="8"
|
||||
x2="32"
|
||||
y2="102"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="2.5 4"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<circle cx="32" cy="22" r="4" fill="currentColor">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.25;0.9;0.25"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="32" cy="55" r="4" fill="currentColor">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.25;0.9;0.25"
|
||||
dur="2.4s"
|
||||
begin="0.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="4;11;4"
|
||||
dur="2.4s"
|
||||
begin="0.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.6;0;0.6"
|
||||
dur="2.4s"
|
||||
begin="0.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="32" cy="88" r="4" fill="currentColor">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.25;0.9;0.25"
|
||||
dur="2.4s"
|
||||
begin="1.2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
<p class="text-[11px] leading-relaxed text-muted-foreground">Waiting for activity…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col py-1">
|
||||
{#each items as item, i (item.kind === 'step' ? item.step.id : item.entry.id)}
|
||||
{@const isFirst = i === 0}
|
||||
{@const isLast = i === items.length - 1}
|
||||
{#if item.kind === 'step'}
|
||||
{@const st = item.step.status}
|
||||
{@const open = stepOpen(item.step)}
|
||||
{@const hasDetail = !!item.step.detail?.trim()}
|
||||
{@const expandable = item.tools.length > 0 || hasDetail}
|
||||
{@const expandedWithTools = open && item.tools.length > 0}
|
||||
<!-- Step node on the backbone -->
|
||||
<div class="relative" data-tl-id={item.step.id}>
|
||||
<span
|
||||
class="pointer-events-none absolute left-[17px] w-px {segClass(
|
||||
st,
|
||||
isFirst,
|
||||
isLast,
|
||||
expandedWithTools
|
||||
)}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs {expandable
|
||||
? 'cursor-pointer hover:bg-muted/30'
|
||||
: 'cursor-default'} {st === 'running' ? 'bg-primary/5' : ''}"
|
||||
onclick={() => expandable && toggleStep(item.step)}
|
||||
aria-expanded={open}
|
||||
disabled={!expandable}
|
||||
>
|
||||
<!-- Filled status node -->
|
||||
<span
|
||||
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full
|
||||
{st === 'done'
|
||||
? 'bg-primary'
|
||||
: st === 'running'
|
||||
? 'bg-background'
|
||||
: st === 'failed'
|
||||
? 'bg-destructive'
|
||||
: st === 'blocked'
|
||||
? 'bg-warning/25 border border-warning'
|
||||
: st === 'skipped' || st === 'replaced'
|
||||
? 'bg-muted'
|
||||
: 'bg-background border border-muted-foreground/40'}"
|
||||
>
|
||||
{#if st === 'running'}
|
||||
<span class="absolute -inset-0.5 animate-ping rounded-full bg-primary/30"
|
||||
></span>
|
||||
<Spinner class="relative size-3.5 text-primary" />
|
||||
{:else if st === 'done'}
|
||||
<CheckIcon class="size-2.5 text-primary-foreground" strokeWidth={3.5} />
|
||||
{:else if st === 'failed'}
|
||||
<XIcon class="size-2.5 text-destructive-foreground" strokeWidth={3.5} />
|
||||
{:else if st === 'blocked'}
|
||||
<PauseIcon class="size-2 text-warning" strokeWidth={3} />
|
||||
{:else if st === 'skipped' || st === 'replaced'}
|
||||
<SlashIcon class="size-2 text-muted-foreground" strokeWidth={3} />
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
title={item.step.title}
|
||||
class="min-w-0 flex-1 leading-snug {open
|
||||
? 'whitespace-normal'
|
||||
: 'truncate'} {st === 'done'
|
||||
? 'text-muted-foreground'
|
||||
: st === 'running'
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
>
|
||||
{item.step.title}
|
||||
</span>
|
||||
{#if hhmm(item.ts)}
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
||||
>{hhmm(item.ts)}</span
|
||||
>
|
||||
{/if}
|
||||
{#if item.tools.length > 0}
|
||||
<span class="shrink-0 text-muted-foreground/60">
|
||||
{#if open}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
||||
class="size-3"
|
||||
/>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if expandedWithTools}
|
||||
<div transition:slide={{ duration: 150 }} class="flex flex-col">
|
||||
{#each item.tools as tool (tool.id)}
|
||||
{@const tOpen = expandedTools.has(tool.id) || !!tool.liveOutput}
|
||||
<div class="relative" data-tl-id={tool.id}>
|
||||
<!-- Branch stub: backbone → tool -->
|
||||
<span
|
||||
class="pointer-events-none absolute left-[17px] top-[9.5px] h-px w-[17px] {tool.status ===
|
||||
'failed'
|
||||
? 'bg-destructive/40'
|
||||
: 'bg-border'}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 py-1 pl-9 pr-3 text-left text-[11px] {tool.args ||
|
||||
tool.detail ||
|
||||
tool.liveOutput
|
||||
? 'cursor-pointer hover:bg-muted/20'
|
||||
: 'cursor-default'}"
|
||||
onclick={() =>
|
||||
(tool.args || tool.detail || tool.liveOutput) && toggleTool(tool.id)}
|
||||
>
|
||||
<span class="flex size-3 shrink-0 items-center justify-center">
|
||||
{#if tool.status === 'running'}
|
||||
<Spinner class="size-2.5 text-primary" />
|
||||
{:else if tool.status === 'failed'}
|
||||
<XIcon class="size-2.5 text-destructive" strokeWidth={3.5} />
|
||||
{:else}
|
||||
<CheckIcon class="size-2.5 text-primary/70" strokeWidth={3.5} />
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
title={tool.description}
|
||||
class="min-w-0 flex-1 truncate leading-snug {tool.status === 'done'
|
||||
? 'text-muted-foreground'
|
||||
: tool.status === 'failed'
|
||||
? 'text-destructive'
|
||||
: 'text-foreground/80'}"
|
||||
>
|
||||
{tool.description}
|
||||
</span>
|
||||
{#if !tool.link}
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
|
||||
>{hhmm(tool.timestamp)}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{#if tool.link}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
title="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
||||
aria-label="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
||||
onclick={() => openEntityWindow(tool.link!.slug)}
|
||||
>
|
||||
<ExternalLinkIcon class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if tOpen}
|
||||
<div
|
||||
transition:slide={{ duration: 120 }}
|
||||
class="flex flex-col gap-1 pb-1.5 pl-[52px] pr-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
<span class="capitalize">{tool.status}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{hhmmss(tool.timestamp)}</span>
|
||||
{#if tool.toolName}<span aria-hidden="true">·</span><code
|
||||
class="font-mono">{tool.toolName}</code
|
||||
>{/if}
|
||||
</div>
|
||||
{#if tool.args}
|
||||
<pre
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(
|
||||
tool.args
|
||||
)}</pre>
|
||||
{/if}
|
||||
{#if tool.liveOutput}
|
||||
<!-- Streaming while the command runs. Bound so it
|
||||
can be pinned to the tail as chunks arrive. -->
|
||||
<pre
|
||||
bind:this={liveOutputEls[tool.id]}
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{tool.liveOutput}</pre>
|
||||
{/if}
|
||||
{#if tool.detail}
|
||||
<pre
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {tool.status ===
|
||||
'failed'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'}">{prettyPrint(tool.detail)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Flat entry: milestone marker on the backbone -->
|
||||
{@const e = item.entry}
|
||||
{@const Icon = entryIcon(e)}
|
||||
{@const eOpen = expandedTools.has(e.id)}
|
||||
<div class="relative" data-tl-id={e.id}>
|
||||
<span
|
||||
class="pointer-events-none absolute left-[17px] w-px {segClass(
|
||||
e.status,
|
||||
isFirst,
|
||||
isLast,
|
||||
false
|
||||
)}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] {e.args ||
|
||||
e.detail
|
||||
? 'cursor-pointer hover:bg-muted/30'
|
||||
: 'cursor-default'}"
|
||||
onclick={() => (e.args || e.detail) && toggleTool(e.id)}
|
||||
>
|
||||
<span
|
||||
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full border bg-background
|
||||
{e.status === 'failed'
|
||||
? 'border-destructive text-destructive'
|
||||
: e.status === 'running'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-border text-primary'}"
|
||||
>
|
||||
{#if e.status === 'running'}
|
||||
<Spinner class="size-2.5" />
|
||||
{:else if e.status === 'failed'}
|
||||
<XIcon class="size-2" strokeWidth={3.5} />
|
||||
{:else}
|
||||
<Icon class="size-2" strokeWidth={2.5} />
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
title={e.description}
|
||||
class="min-w-0 flex-1 truncate leading-snug {e.status === 'done'
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground/80'}"
|
||||
>
|
||||
{e.description}
|
||||
</span>
|
||||
{#if !e.link}
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
||||
>{hhmm(e.timestamp)}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{#if e.link}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
title="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
||||
aria-label="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
||||
onclick={() => openEntityWindow(e.link!.slug)}
|
||||
>
|
||||
<ExternalLinkIcon class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if eOpen}
|
||||
<div
|
||||
transition:slide={{ duration: 120 }}
|
||||
class="flex flex-col gap-1 pb-1.5 pl-9 pr-3"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
|
||||
<span class="capitalize">{e.status}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{hhmmss(e.timestamp)}</span>
|
||||
{#if e.toolName}<span aria-hidden="true">·</span><code class="font-mono"
|
||||
>{e.toolName}</code
|
||||
>{/if}
|
||||
</div>
|
||||
{#if e.args}
|
||||
<pre
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(
|
||||
e.args
|
||||
)}</pre>
|
||||
{/if}
|
||||
{#if e.detail}
|
||||
<pre
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {e.status ===
|
||||
'failed'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'}">{prettyPrint(e.detail)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,25 +80,30 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="group absolute flex flex-col items-center gap-1 rounded-lg p-1.5 pointer-events-auto select-none focus-visible:outline-2 focus-visible:outline-ring {dragging
|
||||
? 'z-50 cursor-grabbing bg-accent/40'
|
||||
: 'cursor-pointer hover:bg-accent/30'}"
|
||||
class="group absolute flex flex-col items-center gap-1.5 p-1.5 pointer-events-auto select-none transition-transform focus-visible:outline-2 focus-visible:outline-ring {dragging
|
||||
? 'z-50 cursor-grabbing'
|
||||
: 'cursor-pointer hover:-translate-y-0.5'}"
|
||||
style="left: {left}px; top: {top}px; width: {GRID.cell}px;"
|
||||
onpointerdown={onPointerDown}
|
||||
onkeydown={onKeydown}
|
||||
title={app.title}
|
||||
>
|
||||
<span
|
||||
class="relative flex size-10 items-center justify-center rounded-xl border bg-card/80 text-foreground shadow-sm backdrop-blur"
|
||||
class="relative flex size-11 items-center justify-center border transition-all {dragging
|
||||
? 'border-foreground bg-foreground text-background shadow-[4px_4px_0_0_var(--foreground)]'
|
||||
: 'border-border bg-card text-foreground group-hover:border-foreground group-hover:bg-foreground group-hover:text-background'}"
|
||||
>
|
||||
<app.icon class="size-5" />
|
||||
<app.icon class="size-5 transition-colors" />
|
||||
{#if badge > 0}
|
||||
<span
|
||||
class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground"
|
||||
class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center border border-background bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground"
|
||||
>
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="max-w-full truncate text-[11px] text-foreground/90">{app.title}</span>
|
||||
<span
|
||||
class="max-w-full truncate text-[10px] uppercase tracking-wider text-muted-foreground transition-colors group-hover:text-foreground"
|
||||
>{app.title}</span
|
||||
>
|
||||
</button>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
{initialDraft}
|
||||
messages={[]}
|
||||
streaming={false}
|
||||
working={false}
|
||||
connectionState="connected"
|
||||
{onSend}
|
||||
onCancel={() => {}}
|
||||
|
||||
@@ -53,10 +53,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t bg-muted/30 px-2">
|
||||
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t border-border bg-background px-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
class="flex shrink-0 items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
|
||||
onclick={toggleShowDesktop}
|
||||
title="Show desktop"
|
||||
>
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
<div class="h-6 w-px shrink-0 bg-border"></div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto py-1.5">
|
||||
{#each buttons as win (win.id)}
|
||||
{@const Icon = iconFor(win.id)}
|
||||
{@const badge = badgeFor(win.id)}
|
||||
@@ -73,12 +73,12 @@
|
||||
<button
|
||||
type="button"
|
||||
data-taskbar-btn={win.id}
|
||||
class="flex h-8 max-w-56 items-center gap-1.5 rounded-md border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
|
||||
class="flex h-8 max-w-56 items-center gap-1.5 border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
|
||||
win.id && win.stage !== 'minimized'
|
||||
? 'border-primary/50 bg-primary/10 text-foreground'
|
||||
: 'border-transparent bg-card/60 text-muted-foreground hover:bg-muted'} {win.stage ===
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border bg-background text-muted-foreground hover:border-foreground hover:bg-foreground hover:text-background'} {win.stage ===
|
||||
'minimized'
|
||||
? 'opacity-60'
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
onclick={() => toggle(win.id, win)}
|
||||
title={win.title}
|
||||
@@ -87,7 +87,7 @@
|
||||
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
|
||||
{#if badge > 0}
|
||||
<span
|
||||
class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground"
|
||||
class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center border border-background bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground"
|
||||
>
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
@@ -95,7 +95,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute -top-1.5 -right-1.5 hidden size-4 items-center justify-center rounded-full bg-muted-foreground/80 text-background hover:bg-destructive group-hover/tb:flex"
|
||||
class="win-ctrl absolute -top-1.5 -right-1.5 hidden size-4 group-hover/tb:flex"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
wm.close(win.id)
|
||||
@@ -113,7 +113,7 @@
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
class="flex items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
|
||||
onclick={() => toggleTheme()}
|
||||
title="Cycle theme ({THEME_LABELS[getTheme()]})"
|
||||
aria-label="Cycle theme, currently {THEME_LABELS[getTheme()]}"
|
||||
@@ -122,12 +122,12 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
class="flex items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
|
||||
onclick={() => openAppWindow('settings')}
|
||||
title="Settings"
|
||||
>
|
||||
<SettingsIcon class="size-4" />
|
||||
</button>
|
||||
<span class="px-1.5 text-[11px] text-muted-foreground select-none">{VERSION}</span>
|
||||
<span class="px-1.5 font-mono text-[11px] text-muted-foreground select-none">{VERSION}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
wm,
|
||||
dk,
|
||||
wmState,
|
||||
windowKeys,
|
||||
openEntityWindow,
|
||||
NEW_TASK_WINDOW_ID,
|
||||
SESSION_PREFIX,
|
||||
@@ -59,7 +60,7 @@
|
||||
</script>
|
||||
|
||||
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
|
||||
{#each $wmState.order as id (id)}
|
||||
{#each $windowKeys as id (id)}
|
||||
{@const win = $wmState.windows[id]}
|
||||
{@const appId = appIdFromWindowId(id)}
|
||||
{@const app = appId ? $appById.get(appId) : undefined}
|
||||
@@ -67,18 +68,18 @@
|
||||
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
|
||||
<header
|
||||
data-wm-drag
|
||||
class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5"
|
||||
class="flex h-9 shrink-0 cursor-move items-center justify-between gap-2 overflow-hidden border-b border-border bg-background px-3"
|
||||
>
|
||||
<span
|
||||
data-wm-title
|
||||
class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium"
|
||||
class="min-w-0 flex-1 truncate font-mono text-xs font-medium"
|
||||
>{win.title}</span
|
||||
>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-wm-minimize
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
class="win-ctrl flex size-6"
|
||||
aria-label="Minimize {win.title}"
|
||||
>
|
||||
<MinusIcon class="size-3.5" />
|
||||
@@ -86,7 +87,7 @@
|
||||
<button
|
||||
type="button"
|
||||
data-wm-maximize
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
class="win-ctrl flex size-6"
|
||||
aria-label="Maximize {win.title}"
|
||||
>
|
||||
<Maximize2Icon class="size-3.5" />
|
||||
@@ -94,7 +95,7 @@
|
||||
<button
|
||||
type="button"
|
||||
data-wm-close
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
class="win-ctrl flex size-6"
|
||||
aria-label="Close {win.title}"
|
||||
>
|
||||
<XIcon class="size-3.5" />
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each stats.topTags as [tag, count] (tag)}
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
class="flex items-center gap-1 border px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
<span class="text-foreground/70 tabular-nums">{count}</span>
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
{#if item.tags.length}
|
||||
<div class="mb-3 flex max-w-[68ch] flex-wrap gap-1.5">
|
||||
{#each item.tags as t (t)}<span
|
||||
class="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
class="border px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
>{t}</span
|
||||
>{/each}
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock('./execstream', () => ({
|
||||
liveExecutionOutputFor: vi.fn(() => writable(null))
|
||||
}))
|
||||
|
||||
import { computeActivityLog } from './activity'
|
||||
import { computeActivityLog, toolResultSummary } from './activity'
|
||||
import type { ChatMessage } from './chat'
|
||||
import type { PlanStep, Session } from '$lib/api'
|
||||
|
||||
@@ -46,6 +46,14 @@ function toolResult(name: string, id: string): NonNullable<ChatMessage['tools']>
|
||||
return { type: 'tool_result', name, id, result: 'ok' }
|
||||
}
|
||||
|
||||
function toolUse(
|
||||
name: string,
|
||||
id: string,
|
||||
args?: Record<string, unknown>
|
||||
): NonNullable<ChatMessage['tools']>[number] {
|
||||
return { type: 'tool_use', name, id, args }
|
||||
}
|
||||
|
||||
describe('computeActivityLog timestamps (P0.2)', () => {
|
||||
it('uses the message created_at for persisted tool calls, not a fabricated spread', () => {
|
||||
const created = '2026-07-29T20:08:10Z'
|
||||
@@ -102,3 +110,138 @@ describe('computeActivityLog timestamps (P0.2)', () => {
|
||||
expect(entries.find((e) => e.id === 's1')!.timestamp).toBe(new Date(started).getTime())
|
||||
})
|
||||
})
|
||||
|
||||
// F4 (plan 2026-08-03): the timeline must be generation-aware. A re-proposed
|
||||
// task persists every generation's propose_plan / update_plan_step calls; before
|
||||
// the fix that produced N "Proposed plan" entries and tagged current-gen tools
|
||||
// with seqs inferred from superseded generations. Only the LAST propose_plan is
|
||||
// the live plan; earlier ones collapse to one "Earlier plan revised" marker, and
|
||||
// step-attribution only follows the current generation.
|
||||
describe('computeActivityLog generation awareness (F4)', () => {
|
||||
it('renders one Proposed plan + a revised marker, and attributes tools to the current gen only', () => {
|
||||
const created = '2026-07-29T20:08:10Z'
|
||||
// Generation 1: propose → step 1 running → run. Then generation 2 (re-plan).
|
||||
const gen1 = msg({
|
||||
id: 'm1',
|
||||
created_at: created,
|
||||
tools: [
|
||||
toolUse('propose_plan', 'p1'),
|
||||
toolUse('update_plan_step', 'u1', { seq: 1, status: 'running' }),
|
||||
toolResult('run', 'r1')
|
||||
]
|
||||
})
|
||||
const gen2 = msg({
|
||||
id: 'm2',
|
||||
created_at: created,
|
||||
tools: [
|
||||
toolUse('propose_plan', 'p2'),
|
||||
toolUse('update_plan_step', 'u2', { seq: 1, status: 'running' }),
|
||||
toolResult('run', 'r2')
|
||||
]
|
||||
})
|
||||
// Current-generation plan step (gen 2), as fetchPlan (MAX generation) returns.
|
||||
const steps: PlanStep[] = [
|
||||
{ id: 's-gen2', seq: 1, title: 'Gen2 step', detail: '', status: 'done', started_at: created }
|
||||
]
|
||||
|
||||
const entries = computeActivityLog([gen1, gen2], steps, null, new Map())
|
||||
|
||||
// Exactly one "Proposed plan" (the current generation's).
|
||||
const proposals = entries.filter((e) => e.description === 'Proposed plan')
|
||||
expect(proposals.length).toBe(1)
|
||||
|
||||
// One collapsed marker for the superseded generation(s).
|
||||
expect(entries.filter((e) => e.description === 'Earlier plan revised').length).toBe(1)
|
||||
|
||||
// The current-gen run is tagged with step 1 (from gen2's update_plan_step).
|
||||
const r2 = entries.find((e) => e.id === 'r2')
|
||||
expect(r2, 'gen2 run entry should exist').toBeDefined()
|
||||
expect(r2!.stepSeq).toBe(1)
|
||||
|
||||
// The superseded-gen run is NOT tagged with a current-gen step (its
|
||||
// update_plan_step belonged to the replaced generation).
|
||||
const r1 = entries.find((e) => e.id === 'r1')
|
||||
expect(r1, 'gen1 run entry should exist').toBeDefined()
|
||||
expect(r1!.stepSeq).toBeUndefined()
|
||||
})
|
||||
|
||||
it('plan-less Q&A still attributes nothing to a step (no propose_plan at all)', () => {
|
||||
const m = msg({ id: 'm1', tools: [toolResult('get_entity', 't1')] })
|
||||
const entries = computeActivityLog([m], [], null, new Map())
|
||||
expect(entries.filter((e) => e.description === 'Proposed plan')).toHaveLength(0)
|
||||
expect(entries.find((e) => e.id === 't1')!.stepSeq).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// toolResultSummary (chat interaction overhaul): a one-line, humanized outcome
|
||||
// per tool so each inline tool line reads as a result instead of raw JSON.
|
||||
describe('toolResultSummary', () => {
|
||||
type TR = NonNullable<ChatMessage['tools']>[number]
|
||||
const done = (name: string, result: unknown, args?: Record<string, unknown>): TR => ({
|
||||
type: 'tool_result',
|
||||
name,
|
||||
id: name,
|
||||
result,
|
||||
args
|
||||
})
|
||||
|
||||
it('is empty for a still-running call and for an errored one', () => {
|
||||
expect(toolResultSummary({ type: 'tool_use', name: 'run', id: 'r' })).toBe('')
|
||||
expect(
|
||||
toolResultSummary({ type: 'tool_result', name: 'run', id: 'r', error: 'boom' })
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('parses run exit status', () => {
|
||||
expect(
|
||||
toolResultSummary(done('run', 'run on lxc:caddy: ERROR exit status 1'))
|
||||
).toContain('exit 1')
|
||||
})
|
||||
|
||||
it('summarizes a clean run with its first line', () => {
|
||||
const s = toolResultSummary(done('run', 'Active: active (running)'))
|
||||
expect(s.startsWith('ok')).toBe(true)
|
||||
expect(s).toContain('active')
|
||||
})
|
||||
|
||||
it('formats get_entity as slug (health)', () => {
|
||||
expect(
|
||||
toolResultSummary(done('get_entity', { slug: 'host:hubris', health: 'healthy' }))
|
||||
).toBe('host:hubris (healthy)')
|
||||
})
|
||||
|
||||
it('counts list results', () => {
|
||||
expect(
|
||||
toolResultSummary(done('list_entities', { entities: Array(10).fill({}) }))
|
||||
).toBe('10 entities')
|
||||
expect(toolResultSummary(done('list_lxcs', { containers: [1, 2] }))).toBe('2 containers')
|
||||
})
|
||||
|
||||
it('formats fleet health counts', () => {
|
||||
expect(
|
||||
toolResultSummary(
|
||||
done('get_health_summary', { health: { healthy: 5, degraded: 1, down: 0, unknown: 2 } })
|
||||
)
|
||||
).toBe('healthy 5 · degraded 1 · down 0 · unknown 2')
|
||||
})
|
||||
|
||||
it('extracts the knowledge slug from upsert_knowledge', () => {
|
||||
expect(
|
||||
toolResultSummary(done('upsert_knowledge', 'Saved document:nomos/foo-bar to the DB'))
|
||||
).toBe('recorded document:nomos/foo-bar')
|
||||
})
|
||||
|
||||
it('formats update_plan_step from args', () => {
|
||||
expect(
|
||||
toolResultSummary(done('update_plan_step', 'ok', { seq: 2, status: 'done' }))
|
||||
).toBe('step 2 → done')
|
||||
})
|
||||
|
||||
it('counts proposed plan steps', () => {
|
||||
expect(toolResultSummary(done('propose_plan', { steps: [{}, {}, {}] }))).toBe('3 steps')
|
||||
})
|
||||
|
||||
it('falls back to the first line for unmapped tools', () => {
|
||||
expect(toolResultSummary(done('some_new_tool', 'first line\nsecond line'))).toBe('first line')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -149,13 +149,45 @@ export function computeActivityLog(
|
||||
|
||||
// Tool calls (from messages). Tag each tool with the plan step that's
|
||||
// currently running when it fires.
|
||||
//
|
||||
// Generation awareness (plan 2026-08-03 F4): a re-proposed task persists
|
||||
// every generation's propose_plan/update_plan_step calls. Without scoping,
|
||||
// the timeline rendered N "Proposed plan" entries and inferred
|
||||
// currentStepSeq from superseded generations — tools landed under the wrong
|
||||
// (current-gen) step and it read as "several plans, some never run." So:
|
||||
// only the LAST propose_plan is the live plan; earlier ones collapse to a
|
||||
// single "Earlier plan revised" marker, and update_plan_step step-tracking
|
||||
// only applies to the current generation.
|
||||
let lastPlanMi = -1
|
||||
let lastPlanTi = -1
|
||||
let planCount = 0
|
||||
for (let mi = 0; mi < $msgs.length; mi++) {
|
||||
for (let ti = 0; ti < $msgs[mi].tools.length; ti++) {
|
||||
if ($msgs[mi].tools[ti].name === 'propose_plan') {
|
||||
planCount++
|
||||
lastPlanMi = mi
|
||||
lastPlanTi = ti
|
||||
}
|
||||
}
|
||||
}
|
||||
const revised = planCount > 1
|
||||
|
||||
let currentStepSeq = 0
|
||||
let entryIdx = 0
|
||||
// No plan at all (plan-less Q&A) → treat the whole transcript as current.
|
||||
let sawCurrentPlan = planCount === 0
|
||||
let emittedRevised = false
|
||||
for (let mi = 0; mi < $msgs.length; mi++) {
|
||||
const msgTs = tsOf($msgs[mi].created_at)
|
||||
for (const t of $msgs[mi].tools) {
|
||||
// Track current step from update_plan_step calls
|
||||
if (t.type === 'tool_use' && t.name === 'update_plan_step') {
|
||||
for (let ti = 0; ti < $msgs[mi].tools.length; ti++) {
|
||||
const t = $msgs[mi].tools[ti]
|
||||
const isLastPlan = mi === lastPlanMi && ti === lastPlanTi
|
||||
if (isLastPlan) sawCurrentPlan = true
|
||||
|
||||
// Track current step ONLY from the current generation's
|
||||
// update_plan_step calls; a superseded generation's seqs would tag
|
||||
// tools with the wrong (current-gen) step.
|
||||
if (sawCurrentPlan && t.type === 'tool_use' && t.name === 'update_plan_step') {
|
||||
const s = typeof t.args?.seq === 'number' ? t.args.seq : undefined
|
||||
const status = typeof t.args?.status === 'string' ? t.args.status : undefined
|
||||
if (s && status === 'running') currentStepSeq = s
|
||||
@@ -163,6 +195,23 @@ export function computeActivityLog(
|
||||
currentStepSeq = 0
|
||||
}
|
||||
|
||||
// Skip superseded-generation propose_plan entries; emit one collapsed
|
||||
// "revised" marker so a re-proposal stays visible without reading as a
|
||||
// second active plan.
|
||||
if (t.name === 'propose_plan' && !isLastPlan) {
|
||||
if (revised && !emittedRevised) {
|
||||
emittedRevised = true
|
||||
entries.push({
|
||||
id: `plan_revised_${mi}_${ti}`,
|
||||
type: 'plan',
|
||||
description: 'Earlier plan revised',
|
||||
timestamp: freeze(`plan_revised_${mi}_${ti}`, msgTs),
|
||||
status: 'done'
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const label = toolActivityLabel(t)
|
||||
const stepTag = currentStepSeq > 0 ? currentStepSeq : undefined
|
||||
const id = t.id ?? `tool_${mi}_${entryIdx++}`
|
||||
@@ -384,3 +433,148 @@ export function toolActivityLabel(t: ToolCallResult): string {
|
||||
return t.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
|
||||
}
|
||||
}
|
||||
|
||||
// ── toolResultSummary ────────────────────────────────────────────────────
|
||||
// A one-line, humanized summary of a tool's RESULT (the Claude-Code-style
|
||||
// "exit 0 · <line>" / "host:hubris (healthy)" affordance) so each tool line
|
||||
// in the inline trace reads as an outcome instead of a raw JSON blob. Empty
|
||||
// for a still-running call (no result yet) or an errored one (the error is
|
||||
// surfaced separately). Best-effort by tool name; the fallback is the first
|
||||
// non-empty line of the stringified result, truncated — never blank (the
|
||||
// expandable raw detail is always one click away).
|
||||
function resultAsString(r: unknown): string {
|
||||
if (r == null) return ''
|
||||
if (typeof r === 'string') return r
|
||||
try {
|
||||
return JSON.stringify(r)
|
||||
} catch {
|
||||
return String(r)
|
||||
}
|
||||
}
|
||||
function firstLine(s: string, max = 80): string {
|
||||
const line = s
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0) ?? ''
|
||||
return line.length > max ? `${line.slice(0, max - 1)}…` : line
|
||||
}
|
||||
function resultArray(r: unknown): unknown[] | null {
|
||||
if (Array.isArray(r)) return r
|
||||
if (r && typeof r === 'object') {
|
||||
const o = r as Record<string, unknown>
|
||||
for (const k of [
|
||||
'entities',
|
||||
'results',
|
||||
'relations',
|
||||
'steps',
|
||||
'items',
|
||||
'containers',
|
||||
'docs',
|
||||
'questions',
|
||||
'signals',
|
||||
'events',
|
||||
'patterns',
|
||||
'skills'
|
||||
]) {
|
||||
if (Array.isArray(o[k])) return o[k] as unknown[]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
function num(v: unknown): number | null {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : null
|
||||
}
|
||||
function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
|
||||
return `${n} ${n === 1 ? singular : pluralForm}`
|
||||
}
|
||||
export function toolResultSummary(t: ToolCallResult): string {
|
||||
if (t.type === 'tool_use') return '' // still running
|
||||
if (t.error) return '' // error surfaced separately
|
||||
const args = t.args ?? {}
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||
const obj = (r: unknown): Record<string, unknown> | null =>
|
||||
r && typeof r === 'object' && !Array.isArray(r) ? (r as Record<string, unknown>) : null
|
||||
switch (t.name) {
|
||||
case 'run': {
|
||||
const s = resultAsString(t.result)
|
||||
const m = s.match(/exit (?:status )?(\d+)/i)
|
||||
const tag = m ? `exit ${m[1]}` : /error/i.test(s) ? 'error' : 'ok'
|
||||
const rest = firstLine(s.replace(/[\s\S]*exit (?:status )?\d+/i, ''), 60)
|
||||
return rest ? `${tag} · ${rest}` : tag
|
||||
}
|
||||
case 'get_entity': {
|
||||
const o = obj(t.result)
|
||||
const slug = str(o?.slug) || str(args.slug_or_id)
|
||||
const health = str(o?.health) || str(o?.state)
|
||||
return [slug, health && `(${health})`].filter(Boolean).join(' ') || 'found'
|
||||
}
|
||||
case 'get_relations': {
|
||||
const a = resultArray(t.result)
|
||||
return a ? plural(a.length, 'relation') : 'done'
|
||||
}
|
||||
case 'list_entities':
|
||||
case 'list_lxcs': {
|
||||
const a = resultArray(t.result)
|
||||
if (!a) return 'done'
|
||||
return t.name === 'list_lxcs'
|
||||
? plural(a.length, 'container')
|
||||
: plural(a.length, 'entity', 'entities')
|
||||
}
|
||||
case 'get_health_summary': {
|
||||
const o = obj(t.result)
|
||||
const h = (o?.health && obj(o.health)) || o
|
||||
if (h) {
|
||||
const parts = ['healthy', 'degraded', 'down', 'unknown']
|
||||
.map((k) => {
|
||||
const n = num((h as Record<string, unknown>)[k])
|
||||
return n != null ? `${k} ${n}` : null
|
||||
})
|
||||
.filter((p): p is string => p != null)
|
||||
if (parts.length) return parts.join(' · ')
|
||||
}
|
||||
return 'done'
|
||||
}
|
||||
case 'get_state_snapshot': {
|
||||
const o = obj(t.result)
|
||||
const drift = num(o?.drift ?? o?.drift_count)
|
||||
return drift != null ? plural(drift, 'drift') : 'done'
|
||||
}
|
||||
case 'search_knowledge':
|
||||
case 'get_entity_knowledge':
|
||||
case 'get_patterns':
|
||||
case 'get_skills': {
|
||||
const a = resultArray(t.result)
|
||||
return a ? plural(a.length, 'result') : firstLine(resultAsString(t.result)) || 'done'
|
||||
}
|
||||
case 'upsert_knowledge': {
|
||||
const m = resultAsString(t.result).match(/[a-z]+:nomos\/[a-z0-9-]+/)
|
||||
return m ? `recorded ${m[0]}` : 'recorded'
|
||||
}
|
||||
case 'update_plan_step': {
|
||||
const seq = num(args.seq)
|
||||
const status = str(args.status)
|
||||
if (seq != null && status) return `step ${seq} → ${status}`
|
||||
return status || 'updated'
|
||||
}
|
||||
case 'propose_plan': {
|
||||
const a = resultArray(t.result) ?? resultArray(args.steps)
|
||||
return a ? plural(a.length, 'step') : 'planned'
|
||||
}
|
||||
case 'set_goal':
|
||||
return 'goal set'
|
||||
case 'complete_task':
|
||||
return str(args.outcome) || 'complete'
|
||||
case 'ask_operator':
|
||||
return 'asked'
|
||||
case 'ping_service': {
|
||||
const s = resultAsString(t.result).toLowerCase()
|
||||
return /ok|reachable|up|healthy/.test(s) ? 'reachable' : firstLine(s, 40) || 'done'
|
||||
}
|
||||
case 'get_execution_status': {
|
||||
const o = obj(t.result)
|
||||
return str(o?.state) || firstLine(resultAsString(t.result), 40) || 'done'
|
||||
}
|
||||
default:
|
||||
return firstLine(resultAsString(t.result)) || 'done'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
thinking?: string
|
||||
tools: ToolCallResult[]
|
||||
pendingApprovals: PendingApproval[]
|
||||
created_at?: string
|
||||
@@ -72,6 +73,21 @@ function mid(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
// dropOptimisticAssistantBubble removes the trailing empty assistant message
|
||||
// that sendSessionMessage/startTask optimistically append — used when a turn is
|
||||
// QUEUED behind an in-flight one (plan 2026-08-03 F2): no live assistant stream
|
||||
// is attached, so the empty placeholder must go (otherwise it lingers as a
|
||||
// blank bubble). Shared so the guard can't drift between the two call sites.
|
||||
function dropOptimisticAssistantBubble(messages: Writable<ChatMessage[]>): void {
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant' && last.text === '' && last.tools.length === 0) {
|
||||
return ms.slice(0, -1)
|
||||
}
|
||||
return ms
|
||||
})
|
||||
}
|
||||
|
||||
export const messages = writable<ChatMessage[]>([])
|
||||
export const streaming = writable(false)
|
||||
export const connectionState = writable<'connected' | 'disconnected' | 'reconnecting'>('connected')
|
||||
@@ -208,6 +224,7 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
|
||||
id: m.id,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
text: content?.text ?? '',
|
||||
thinking: content?.thinking ?? undefined,
|
||||
tools,
|
||||
pendingApprovals: extractApprovals(tools),
|
||||
created_at: m.created_at
|
||||
@@ -215,6 +232,16 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
|
||||
})
|
||||
}
|
||||
|
||||
function chatMessagesChanged(a: ChatMessage[], b: ChatMessage[]): boolean {
|
||||
if (a.length !== b.length) return true
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i].id !== b[i].id || a[i].role !== b[i].role || a[i].text !== b[i].text || a[i].tools.length !== b[i].tools.length) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function loadSessionMessages(sessionId: string) {
|
||||
currentSession.set(sessionId)
|
||||
// This is a fresh view of sessionId's current (REST-loaded) state — reset
|
||||
@@ -255,15 +282,11 @@ function startPolling(sessionId: string) {
|
||||
if (pollingSessionId !== sessionId || get(currentSession) !== sessionId) return
|
||||
const msgs = await fetchMessages(sessionId)
|
||||
if (get(streaming) || pollingSessionId !== sessionId) return // re-check: the fetch itself takes time
|
||||
// No cheap "anything new?" check: the auto-continuation worker updates a
|
||||
// placeholder message IN PLACE as each tool call lands (see
|
||||
// cmd/nomos/continue.go), so the message COUNT stays the same while the
|
||||
// content changes — a length-only diff (the previous version of this
|
||||
// code) never detected those updates and progress looked frozen even
|
||||
// though the backend was actively working. Just re-set every tick;
|
||||
// Svelte's own diffing keeps the actual re-render cheap.
|
||||
sessionMessages.set(msgs)
|
||||
messages.set(toChatMessages(msgs))
|
||||
const incoming = toChatMessages(msgs)
|
||||
if (chatMessagesChanged(get(messages), incoming)) {
|
||||
sessionMessages.set(msgs)
|
||||
messages.set(incoming)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
@@ -386,7 +409,15 @@ export function sendMessage(text: string) {
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||
if (ev.is_thinking) {
|
||||
ms[ms.length - 1] = {
|
||||
...last,
|
||||
thinking: (last.thinking || '') + ev.data,
|
||||
text: ''
|
||||
}
|
||||
} else {
|
||||
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||
}
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
@@ -581,7 +612,10 @@ function startSessionPolling(sessionId: string) {
|
||||
if (get(chat.streaming) && get(chat.connectionState) === 'connected') return
|
||||
const msgs = await fetchMessages(sessionId)
|
||||
if (get(chat.streaming)) return // re-check: the fetch itself takes time
|
||||
chat.messages.set(toChatMessages(msgs))
|
||||
const incoming = toChatMessages(msgs)
|
||||
if (chatMessagesChanged(get(chat.messages), incoming)) {
|
||||
chat.messages.set(incoming)
|
||||
}
|
||||
// F3 safety net: if we're recovering from a dropped SSE but the
|
||||
// session's task has already reached a turn-ended status, clear the
|
||||
// stuck disconnected/streaming flags. Catches the edge where the
|
||||
@@ -657,6 +691,16 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
||||
sessionId,
|
||||
(ev: ChatEvent) => {
|
||||
if (ev.type === 'session') return // sessionId is already known for a window
|
||||
if (ev.type === 'queued') {
|
||||
// This message was queued behind an in-flight turn (plan 2026-08-03
|
||||
// F2): no assistant stream is attached to this response. Drop the
|
||||
// optimistic empty assistant bubble so the user message is the last
|
||||
// thing on screen — the thread then shows a "Queued" hint while the
|
||||
// session is working, and the poller surfaces the queued turn's
|
||||
// result once it runs server-side.
|
||||
dropOptimisticAssistantBubble(chat.messages)
|
||||
return
|
||||
}
|
||||
if (ev.type === 'tool_use') {
|
||||
const tr: ToolCallResult = {
|
||||
type: 'tool_use',
|
||||
@@ -703,7 +747,15 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
||||
chat.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||
if (ev.is_thinking) {
|
||||
ms[ms.length - 1] = {
|
||||
...last,
|
||||
thinking: (last.thinking || '') + ev.data,
|
||||
text: ''
|
||||
}
|
||||
} else {
|
||||
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||
}
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
@@ -791,6 +843,13 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
||||
function apply(ev: ChatEvent) {
|
||||
const c = chat
|
||||
if (!c || !sessionId) return
|
||||
if (ev.type === 'queued') {
|
||||
// Defensive: a brand-new task won't normally queue (its session has no
|
||||
// in-flight turn), but handle it symmetrically with sendSessionMessage —
|
||||
// drop the optimistic empty assistant bubble. See plan 2026-08-03 F2.
|
||||
dropOptimisticAssistantBubble(c.messages)
|
||||
return
|
||||
}
|
||||
if (ev.type === 'tool_use') {
|
||||
const tr: ToolCallResult = {
|
||||
type: 'tool_use',
|
||||
@@ -837,7 +896,15 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
||||
c.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||
if (ev.is_thinking) {
|
||||
ms[ms.length - 1] = {
|
||||
...last,
|
||||
thinking: (last.thinking || '') + ev.data,
|
||||
text: ''
|
||||
}
|
||||
} else {
|
||||
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||
}
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
|
||||
@@ -42,6 +42,6 @@ export function toggleTheme(): Theme {
|
||||
}
|
||||
|
||||
export const THEME_LABELS: Record<Theme, string> = {
|
||||
light: 'Terracotta',
|
||||
dark: 'Carbon'
|
||||
light: 'Light',
|
||||
dark: 'Dark'
|
||||
}
|
||||
|
||||
@@ -51,6 +51,22 @@ export const dk = createDesktop(wm, {
|
||||
})
|
||||
export const wmState = wmStore(wm)
|
||||
|
||||
// Stable insertion-order window IDs — unlike $wmState.order (which reorders on
|
||||
// focus/raise), this only changes when a window is opened or closed. Used by
|
||||
// WindowLayer's {#each} so the DOM order stays stable; wmkit handles visual
|
||||
// stacking via z-index in syncAll(). Without this, every focus change moves
|
||||
// <section> elements in the DOM, which resets scroll positions of scrollable
|
||||
// children in Chrome.
|
||||
let _lastKeys: string[] = []
|
||||
export const windowKeys = derived(wmState, ($s) => {
|
||||
const keys = Object.keys($s.windows)
|
||||
if (keys.length === _lastKeys.length && keys.every((k, i) => k === _lastKeys[i])) {
|
||||
return _lastKeys
|
||||
}
|
||||
_lastKeys = keys
|
||||
return keys
|
||||
})
|
||||
|
||||
// The session id backing whichever task/chat window currently has focus, or
|
||||
// null when no task window is focused (Tasks app, an entity window, or
|
||||
// nothing at all). The desktop mascot's stimuli (stimuli.ts) key off this so
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { writable, derived, get, type Writable, type Readable } from 'svelte/store'
|
||||
import { liveEvents, subscribeEvents } from './events'
|
||||
import { currentSession, sessions, loadSessions } from './chat'
|
||||
import { currentSession, sessions, loadSessions, chatFor, streaming } from './chat'
|
||||
import {
|
||||
fetchPlan,
|
||||
fetchQuestions,
|
||||
@@ -271,10 +271,13 @@ export function startWorkspace(): () => void {
|
||||
if (!sid) return
|
||||
// Oldest-first application so ordering (e.g. plan.step.started before
|
||||
// .finished) is preserved.
|
||||
let planAffecting = false
|
||||
for (const e of fresh.slice().reverse()) {
|
||||
applyEventTo(globalWorkspace, sid, e)
|
||||
applyHealthChangedTo(globalWorkspace, e)
|
||||
if (e.correlation_id === sid && STATUS_AFFECTING.has(e.type)) planAffecting = true
|
||||
}
|
||||
if (planAffecting) schedulePlanRefetch(globalWorkspace, sid)
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -306,12 +309,62 @@ export function taskFor(sessionId: string): Readable<Session | null> {
|
||||
return derived(sessions, ($sessions) => $sessions.find((s) => s.id === sessionId) ?? null)
|
||||
}
|
||||
|
||||
// Session statuses where the server is actively running a turn for this task.
|
||||
// Deliberately EXCLUDES `awaiting_input` (paused for the operator) and the
|
||||
// terminal states (done/failed/abandoned). This is the reliable "the agent is
|
||||
// working" truth that survives a dropped SSE stream or an autonomous/background
|
||||
// turn (which has no chat stream at all) — see plan 2026-08-03 F1.
|
||||
const ACTIVE_TURN_STATUS = new Set(['planning', 'executing'])
|
||||
|
||||
function isWorking($streaming: boolean, $task: Session | null): boolean {
|
||||
return $streaming || (!!$task && !!$task.status && ACTIVE_TURN_STATUS.has($task.status))
|
||||
}
|
||||
|
||||
// taskWorking(sessionId): true while this session has a live stream OR its
|
||||
// server-side status says a turn is running. Used by the chat window's
|
||||
// "working" indicator, trace running state, and the activity spinner so a
|
||||
// background/long/desynced turn still looks alive (the symptom: "can't tell
|
||||
// the agent is working").
|
||||
export function taskWorking(sessionId: string): Readable<boolean> {
|
||||
const chat = chatFor(sessionId)
|
||||
return derived([chat.streaming, taskFor(sessionId)], ([$s, $t]) => isWorking($s, $t))
|
||||
}
|
||||
|
||||
// Global "current session" working signal for the main view's panel.
|
||||
export const currentWorking = derived(
|
||||
[streaming, currentTask],
|
||||
([$s, $t]) => isWorking($s, $t)
|
||||
)
|
||||
|
||||
async function hydrateSession(ws: WorkspaceState, sessionId: string) {
|
||||
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
|
||||
ws.planSteps.set(steps)
|
||||
ws.questions.set(qs)
|
||||
}
|
||||
|
||||
// Self-heal for the plan panel (plan 2026-08-03 F4): plan steps are otherwise
|
||||
// driven ONLY by live plan.proposed/plan.step.* events plus a one-time hydrate
|
||||
// on mount. If an event is missed (window opened mid-turn, a brief events-
|
||||
// stream gap), the panel freezes on a stale generation. Refetching the plan
|
||||
// (current generation) on any task-lifecycle event makes it converge back to
|
||||
// truth. Debounced per session since several of these land in one burst.
|
||||
const planRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
function schedulePlanRefetch(ws: WorkspaceState, sessionId: string) {
|
||||
const existing = planRefreshTimers.get(sessionId)
|
||||
if (existing) clearTimeout(existing)
|
||||
planRefreshTimers.set(
|
||||
sessionId,
|
||||
setTimeout(async () => {
|
||||
planRefreshTimers.delete(sessionId)
|
||||
try {
|
||||
ws.planSteps.set(await fetchPlan(sessionId))
|
||||
} catch {
|
||||
// network blip — the next lifecycle event retries
|
||||
}
|
||||
}, 400)
|
||||
)
|
||||
}
|
||||
|
||||
export function startSessionWorkspace(sessionId: string): () => void {
|
||||
const ws = workspaceFor(sessionId)
|
||||
const unsub = subscribeEvents()
|
||||
@@ -327,10 +380,13 @@ export function startSessionWorkspace(sessionId: string): () => void {
|
||||
if (maxId <= lastSeen) return
|
||||
const fresh = evs.filter((e) => e.id > lastSeen)
|
||||
lastSeen = maxId
|
||||
let planAffecting = false
|
||||
for (const e of fresh.slice().reverse()) {
|
||||
applyEventTo(ws, sessionId, e)
|
||||
applyHealthChangedTo(ws, e)
|
||||
if (e.correlation_id === sessionId && STATUS_AFFECTING.has(e.type)) planAffecting = true
|
||||
}
|
||||
if (planAffecting) schedulePlanRefetch(ws, sessionId)
|
||||
})
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface ChatTextDeltaEvent {
|
||||
export interface ChatTextEvent {
|
||||
type: 'text'
|
||||
data: string
|
||||
is_thinking?: boolean
|
||||
}
|
||||
|
||||
export interface ChatDoneEvent {
|
||||
@@ -39,6 +40,14 @@ export interface ChatErrorEvent {
|
||||
data: string
|
||||
}
|
||||
|
||||
// The turn was queued behind an in-flight turn for this session (plan
|
||||
// 2026-08-03 F2). No live assistant stream follows in this response; the
|
||||
// queued turn runs server-side when the gate frees and the poller surfaces it.
|
||||
export interface ChatQueuedEvent {
|
||||
type: 'queued'
|
||||
data: string // session id
|
||||
}
|
||||
|
||||
export type ChatEvent =
|
||||
| ChatSessionEvent
|
||||
| ChatToolUseEvent
|
||||
@@ -47,6 +56,7 @@ export type ChatEvent =
|
||||
| ChatTextEvent
|
||||
| ChatDoneEvent
|
||||
| ChatErrorEvent
|
||||
| ChatQueuedEvent
|
||||
|
||||
// ---- Tool call result (merged from tool_use + tool_result SSE pairs) ----
|
||||
|
||||
@@ -62,12 +72,18 @@ export interface ToolCallResult {
|
||||
// running, so the tool card can show output as it arrives instead of all at
|
||||
// once when the tool_result lands. Not present on persisted/historical calls.
|
||||
liveOutput?: string
|
||||
// Plan step this call belongs to (current generation only). Attached by
|
||||
// ChatThread from the activity log so the inline trace can group a turn's
|
||||
// tool calls under their step. Undefined for orphan calls (no plan / older
|
||||
// generation / plan-less Q&A).
|
||||
stepSeq?: number
|
||||
}
|
||||
|
||||
// ---- Message content (persisted messages from /agent/sessions/:id) ----
|
||||
|
||||
export interface MessageContent {
|
||||
text?: string
|
||||
thinking?: string
|
||||
tool_calls?: ToolCallResult[]
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
|
||||
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
|
||||
import ConfigBackground from '$lib/components/ConfigBackground.svelte'
|
||||
import RasterImage from '$lib/components/RasterImage.svelte'
|
||||
import type { WailsGlobal } from '$lib/types'
|
||||
|
||||
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
|
||||
@@ -93,11 +94,12 @@
|
||||
|
||||
<div class="relative z-10 flex h-full items-center justify-center p-6">
|
||||
<div
|
||||
class="w-full max-w-[26rem] space-y-8 rounded-2xl border border-white/8 bg-card/60 p-8 shadow-2xl shadow-black/40 backdrop-blur-xl"
|
||||
class="w-full max-w-[26rem] space-y-8 border border-border bg-card p-8 shadow-[3px_3px_0_0_var(--border)]"
|
||||
>
|
||||
<!-- Logo + heading -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<svg viewBox="0 0 91 100" class="h-16 w-16 fill-white/90" aria-hidden="true">
|
||||
<RasterImage src="/mascot/egg-idle.png" alt="" width={72} bias={16} />
|
||||
<svg viewBox="0 0 91 100" class="h-12 w-12 fill-foreground/90" aria-hidden="true">
|
||||
<path
|
||||
d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z"
|
||||
/>
|
||||
@@ -128,7 +130,7 @@
|
||||
{#if showOidcContinue}
|
||||
<!-- OIDC authenticated state -->
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-xl border border-white/5 bg-background/40 p-5"
|
||||
class="flex flex-col items-center gap-3 border border-border bg-background/40 p-5"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Logged in as <span class="font-semibold text-foreground">{oidcUser}</span>
|
||||
|
||||
@@ -99,9 +99,9 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (filter = f.id)}
|
||||
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-card/60 text-muted-foreground backdrop-blur hover:bg-muted/50'}"
|
||||
class="border px-2.5 py-1 text-xs transition-colors {filter === f.id
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'}"
|
||||
>
|
||||
{f.label}
|
||||
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
|
||||
|
||||
224
web/vendor/LICENSE
vendored
Normal file
224
web/vendor/LICENSE
vendored
Normal file
@@ -0,0 +1,224 @@
|
||||
"Commons Clause" License Condition v1.0
|
||||
|
||||
The Software is provided to you by the Licensor under the License, as defined
|
||||
below, subject to the following condition.
|
||||
|
||||
Without limiting other conditions in the License, the grant of rights under the
|
||||
License will not include, and the License does not grant to you, the right to
|
||||
Sell the Software.
|
||||
|
||||
For purposes of the foregoing, "Sell" means practicing any or all of the rights
|
||||
granted to you under the License to provide to third parties, for a fee or other
|
||||
consideration (including without limitation fees for hosting or consulting/
|
||||
support services related to the Software), a product or service whose value
|
||||
derives, entirely or substantially, from the functionality of the Software. Any
|
||||
license notice or attribution required by the License must also include this
|
||||
Commons Clause License Condition notice.
|
||||
|
||||
Software: Orby
|
||||
License: Apache License 2.0
|
||||
Licensor: Joan Sterjo
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
7
web/vendor/NOTICE
vendored
Normal file
7
web/vendor/NOTICE
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
Orby
|
||||
Copyright 2026 Joan Sterjo
|
||||
|
||||
This product includes software developed by Joan Sterjo.
|
||||
|
||||
The Apache License 2.0 grant is subject to the Commons Clause License
|
||||
Condition v1.0. See LICENSE for the complete terms.
|
||||
943
web/vendor/README.md
vendored
Normal file
943
web/vendor/README.md
vendored
Normal file
@@ -0,0 +1,943 @@
|
||||
<p align="center">
|
||||
<img
|
||||
src="./docs/assets/readme-hero.svg"
|
||||
alt="Orby — A procedural glyph engine by Joan Sterjo, shown as a deterministic pixel field"
|
||||
width="100%"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Orby</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>A procedural glyph engine by Joan Sterjo.</strong><br />
|
||||
Design one visual identity, invoke semantic states from product code, and keep
|
||||
every transition responsive, reproducible, and alive.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://joansterjo-celonis.github.io/Procedural-glyph-engine/"><strong>Open the live Studio</strong></a>
|
||||
·
|
||||
<a href="https://joansterjo-celonis.github.io/Procedural-glyph-engine/#download">Download v5.0.0</a>
|
||||
·
|
||||
<a href="./docs/QUICKSTART.md">Quick-start guide</a>
|
||||
·
|
||||
<a href="./examples/README.md">Runnable examples</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version 5.0.0" src="https://img.shields.io/badge/version-5.0.0-555555?style=flat-square&labelColor=111111" />
|
||||
<img alt="Canvas 2D and native ESM" src="https://img.shields.io/badge/runtime-Canvas_2D_%C2%B7_ESM-555555?style=flat-square&labelColor=111111" />
|
||||
<img alt="Zero runtime dependencies" src="https://img.shields.io/badge/dependencies-0-555555?style=flat-square&labelColor=111111" />
|
||||
<img alt="TypeScript declarations included" src="https://img.shields.io/badge/types-TypeScript-555555?style=flat-square&labelColor=111111" />
|
||||
<img alt="Source-available license: Apache 2.0 with Commons Clause" src="https://img.shields.io/badge/license-Apache_2.0_%2B_Commons_Clause-555555?style=flat-square&labelColor=111111" />
|
||||
</p>
|
||||
|
||||
Orby turns product intent—ready, listening, thinking, using a tool, progressing,
|
||||
completed, failed—into a coherent live glyph. Each frame combines an analytic
|
||||
silhouette, a seeded field, a persistent pixel gate, and a spatial transition.
|
||||
The result is motion with identity, not a generic loading ornament.
|
||||
|
||||
Orby is distributed as `@joan/procedural-glyph-engine`. Its primary runtime
|
||||
class is `JoanGlyphEngine`, its browser-native element is `<joan-glyph>`, and
|
||||
portable Studio recipes use the `.joan.json` format.
|
||||
|
||||
The runtime has no third-party dependencies. It ships as native ES modules with
|
||||
TypeScript declarations, a browser-native web component, deterministic exports,
|
||||
and a complete offline Studio.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Orby is source-available under the [Apache License 2.0 subject to the
|
||||
> Commons Clause License Condition v1.0](./LICENSE). You may use, copy, modify,
|
||||
> and redistribute it, including inside a larger value-added product. You may
|
||||
> not sell Orby or a product or service whose value derives entirely or
|
||||
> substantially from Orby's functionality.
|
||||
|
||||
## Choose your path
|
||||
|
||||
| I want to… | Start here |
|
||||
| --- | --- |
|
||||
| Explore states and tune a recipe | [Open the live Studio](https://joansterjo-celonis.github.io/Procedural-glyph-engine/#playground) |
|
||||
| Download everything for offline use | [Get the complete integration kit](https://joansterjo-celonis.github.io/Procedural-glyph-engine/#download) |
|
||||
| Integrate the runtime into a product | [Follow the focused quick-start guide](./docs/QUICKSTART.md) |
|
||||
| Try plain Canvas, web component, or timed sequences | [Run the included examples](./examples/README.md) |
|
||||
| Understand the engine deeply | [Architecture](#architecture) · [state language](#the-25-semantic-sprites) · [API](#api-shape) |
|
||||
| Operate it responsibly | [Accessibility](#accessibility-and-reduced-motion) · [performance](#performance-guidance) · [development](#development-test-and-build) |
|
||||
|
||||
<details>
|
||||
<summary><strong>Complete contents</strong></summary>
|
||||
|
||||
- [Quick start](#quick-start)
|
||||
- [Why Orby](#why-orby)
|
||||
- [What Orby includes](#what-orby-includes)
|
||||
- [Self-service download kit](#self-service-download-kit)
|
||||
- [Architecture](#architecture)
|
||||
- [The 25 semantic sprites](#the-25-semantic-sprites)
|
||||
- [Runtime API](#runtime-api)
|
||||
- [Web component](#web-component)
|
||||
- [Custom glyphs](#custom-glyphs)
|
||||
- [Signals, audio, and progress](#signals-audio-and-progress)
|
||||
- [Export](#export)
|
||||
- [Timed state sequences](#timed-and-pre-recorded-state-sequences)
|
||||
- [StateDirector](#statedirector)
|
||||
- [Studio preset library](#studio-preset-library)
|
||||
- [Accessibility and reduced motion](#accessibility-and-reduced-motion)
|
||||
- [Development, test, and build](#development-test-and-build)
|
||||
- [Performance guidance](#performance-guidance)
|
||||
- [License](#license)
|
||||
|
||||
</details>
|
||||
|
||||
## Quick start
|
||||
|
||||
Install the extracted integration kit from a consuming project:
|
||||
|
||||
```sh
|
||||
npm install ./joan-procedural-glyph-engine-5.0.0
|
||||
```
|
||||
|
||||
Mount one long-lived engine instance, then drive it with real product state:
|
||||
|
||||
```html
|
||||
<canvas id="ai-glyph" width="160" height="160"></canvas>
|
||||
|
||||
<script type="module">
|
||||
import { createGlyph } from "@joan/procedural-glyph-engine";
|
||||
|
||||
const glyph = createGlyph("#ai-glyph", {
|
||||
sprite: "ai.idle",
|
||||
seed: "conversation-42",
|
||||
gridSize: 68,
|
||||
});
|
||||
|
||||
await glyph.transitionTo("ai.thinking");
|
||||
await glyph.transitionTo("status.success", {
|
||||
transition: "path-draw",
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
For direct, unbundled browser use, replace the package import with
|
||||
`./src/joan-engine.js`. For a declarative integration, use the included
|
||||
[`<joan-glyph>` web component](#web-component). For pre-recorded or timed flows,
|
||||
use [`playStateSequence()`](#timed-and-pre-recorded-state-sequences).
|
||||
|
||||
## Why Orby
|
||||
|
||||
| Semantic by default | Deterministic by design | Portable by construction |
|
||||
| --- | --- | --- |
|
||||
| Twenty-five states cover the real lifecycle of AI work, from ambient readiness to completion and recovery. | A stable seed preserves visual identity across frames, products, previews, and exports. | Use Canvas 2D, native ESM, a web component, serialized configurations, or a single offline HTML Studio. |
|
||||
|
||||
| 25 semantic states | 19 seeded fields | 12 transitions | 12 switch systems | 9 pixel geometries | 68×68 default grid |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| Presence → recovery | Quiet → chaotic | Morph → ignite | Dither → trace | Disc → composites | Adaptive quality |
|
||||
|
||||
## What Orby includes
|
||||
|
||||
- 25 immutable semantic sprite recipes covering presence, field-only ambient
|
||||
motion, AI activity, status, transfer, and handoff states.
|
||||
- 19 seeded scalar fields: `fbm`, `ridged`, `domain-warp`, `curl`, `flow`,
|
||||
`worley`, `voronoi`, `plasma`, `interference`, `vortex`, `metaballs`,
|
||||
`caustics`, `strata`, `radar`, `constellation`, `liquid`, `electric`,
|
||||
`ripple`, and `kaleidoscope`.
|
||||
- 12 pixel-switch strategies: `ordered-dither`, `temporal-blue-noise`,
|
||||
`threshold-hysteresis`, `sdf-wavefront`, `contour-trace`, `curl-advect`,
|
||||
`neighbor-propagation`, `radial-cascade`, `path-draw`, `axis-flip`,
|
||||
`seeded-dissolve`, and `field-morph`.
|
||||
- 12 transition strategies: `field-morph`, `seeded-dissolve`,
|
||||
`radial-cascade`, `angular-sweep`, `scanline`, `contour-trace`, `path-draw`,
|
||||
`axis-flip`, `cluster-dissolve`, `neighbor-ignite`, `glitch-bands`, and
|
||||
`instant`.
|
||||
- Nine Canvas 2D pixel shapes: `disc`, `square`, `diamond`, `capsule`, `line`,
|
||||
`ring`, `cross`, `square-cross`, and `square-cross-ring`. The composite
|
||||
shapes choose one primitive per cell from its sampled signal lightness.
|
||||
- Analytic glyph masks with progress, audio-energy, pointer, press, signal, and
|
||||
reduced-motion inputs.
|
||||
- Seeded ordered dithering, dwell time, hysteresis, afterglow, and spring
|
||||
response so pixels switch deliberately instead of flickering at a threshold.
|
||||
- Runtime registration of sampler functions and bitmap glyphs, plus browser
|
||||
image-file import.
|
||||
- PNG, JSON-safe configuration, static SVG, and deterministic frame-sampled
|
||||
animated SVG export without mutating the live engine.
|
||||
- A semantic timing helper for escalating long-running reasoning states.
|
||||
- Serializable named state sequences plus one-off timed playback with
|
||||
cancellation, pause, resume, and stop controls.
|
||||
|
||||
## Self-service download kit
|
||||
|
||||
The website's **Download** section publishes a versioned ZIP assembled from the
|
||||
same runtime source used by the live Studio. The complete integration kit
|
||||
contains:
|
||||
|
||||
- the complete native ESM runtime in `src/`;
|
||||
- all TypeScript declarations in `types/`;
|
||||
- this full API and integration reference;
|
||||
- a focused quick start in `docs/QUICKSTART.md`;
|
||||
- runnable canvas, web-component, and sequence examples;
|
||||
- the self-contained `joan-engine-v5.standalone.html` Studio;
|
||||
- package metadata and a runtime manifest;
|
||||
- the complete `LICENSE` and `NOTICE`; and
|
||||
- `SHA256SUMS.txt` covering every packaged payload file.
|
||||
|
||||
Beside the ZIP, the website publishes its `.sha256` file and a machine-readable
|
||||
release manifest. After extracting the kit, install that local folder from a
|
||||
consuming project—the package is not currently registry-published:
|
||||
|
||||
```sh
|
||||
npm install ./joan-procedural-glyph-engine-5.0.0
|
||||
```
|
||||
|
||||
Alternatively, serve the extracted folder and import `./src/joan-engine.js`
|
||||
directly, or open the standalone Studio without a build step. The archive grants
|
||||
the same source-available permissions—and carries the same no-sale condition—as
|
||||
the repository. See [`LICENSE`](./LICENSE) for the complete terms.
|
||||
|
||||
## Architecture
|
||||
|
||||
The renderer is intentionally layered. Each layer can be used independently or
|
||||
composed by `JoanGlyphEngine`.
|
||||
|
||||
| Module | Responsibility |
|
||||
| --- | --- |
|
||||
| `src/fields.js` | Seed hashing, gradient/value/cellular noise, Bayer dithering, and the canonical scalar-field registry. Every named field samples to `0..1`. |
|
||||
| `src/glyphs.js` | Resolution-aware analytic glyph coverage functions. Coordinates are normalized to `-1..1`; coverage is `0..1`. |
|
||||
| `src/sprites.js` | Deep-frozen semantic recipes: glyph, field stack, palette, switching, timing, interaction, labels, and reduced-motion representation. |
|
||||
| `src/joan-engine.js` | Canvas lifecycle, state transitions, interaction impulses, pixel gating, spring dynamics, drawing, exports, and events. |
|
||||
| `src/web-component.js` | The `<joan-glyph>` custom element and its attribute-to-engine adapter. |
|
||||
| `src/state-director.js` | Optional, explicit orchestration for thinking, deep-thinking, still-working, completion, failure, and reset. |
|
||||
| `src/state-sequence.js` | Reusable, serializable timelines for timed state choreography, playback control, and cancellation. |
|
||||
| `src/studio.js` | Interactive demo/studio wiring. It is not required by the runtime. |
|
||||
|
||||
The frame pipeline keeps meaning, identity, motion, and output as explicit
|
||||
layers:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Semantic state] --> B[Analytic glyph or field orb]
|
||||
A --> C[Seeded procedural field stack]
|
||||
D[Signals and interaction] --> B
|
||||
D --> C
|
||||
B --> F[Spatial transition]
|
||||
C --> F
|
||||
F --> E[Dither · hysteresis · dwell pixel gate]
|
||||
E --> G[Spring and afterglow]
|
||||
G --> H[Canvas · PNG · SVG]
|
||||
```
|
||||
|
||||
Seeds affect the field permutation and per-cell decisions. The same seed,
|
||||
sprite, coordinates, time, and options produce the same field samples. Animation
|
||||
time and live interaction still intentionally change a rendered frame.
|
||||
|
||||
## The 25 semantic sprites
|
||||
|
||||
These IDs are the stable built-in catalog. Short aliases such as `thinking`,
|
||||
`success`, `error`, `upload`, and `handoff` are accepted, but product code should
|
||||
prefer the canonical IDs.
|
||||
|
||||
<details>
|
||||
<summary><strong>View all 25 canonical state IDs</strong></summary>
|
||||
|
||||
|
||||
| Canonical ID | Default label | Intended meaning |
|
||||
| --- | --- | --- |
|
||||
| `ai.idle` | Ready | Ready and available |
|
||||
| `ai.ambient-idle` | Ambient ready | Calm presence expressed only through a field |
|
||||
| `ai.ambient-thinking` | Ambient thinking | Reasoning expressed only through a field |
|
||||
| `ai.ambient-thinking-symmetric` | Ambient thinking — symmetric | Clean, symmetrical reasoning expressed only through a field |
|
||||
| `ai.ambient-speaking` | Ambient speaking | Voice output expressed only through a field |
|
||||
| `ai.listening` | Listening | Capturing voice or input |
|
||||
| `ai.thinking` | Thinking | Reasoning |
|
||||
| `ai.thinking-deep` | Reasoning deeply | Deliberate extended reasoning |
|
||||
| `ai.still-working` | Still working | Work is taking longer than expected |
|
||||
| `ai.loading` | Loading | Indeterminate startup or wait |
|
||||
| `ai.progress` | In progress | Determinate completion progress |
|
||||
| `ai.generating` | Generating | Producing content |
|
||||
| `ai.searching` | Searching | Searching or retrieving information |
|
||||
| `ai.tool-use` | Using a tool | Executing a tool or action |
|
||||
| `ai.speaking` | Speaking | Producing voice output |
|
||||
| `ai.awaiting-input` | Your input is needed | User action is required |
|
||||
| `status.success` | Completed | Completed successfully |
|
||||
| `status.warning` | Warning | Attention is needed for a nonfatal issue |
|
||||
| `status.error` | Error | Operation failed |
|
||||
| `status.paused` | Paused | Work is suspended and can resume |
|
||||
| `status.cancelled` | Cancelled | Operation was stopped |
|
||||
| `status.offline` | Offline | Disconnected or unavailable |
|
||||
| `transfer.active` | Transferring | Uploading, downloading, or synchronizing data |
|
||||
| `workflow.handoff` | Handing off | Passing work to another agent or person |
|
||||
| `status.celebration` | Milestone completed | A milestone or high-value success |
|
||||
|
||||
</details>
|
||||
|
||||
Use `listSprites()` to obtain the frozen ordered catalog and `getSprite(id)` to
|
||||
resolve either a canonical ID or alias.
|
||||
|
||||
## Runtime API
|
||||
|
||||
### Package entry points
|
||||
|
||||
Every JavaScript entry point is native ESM and carries TypeScript declarations.
|
||||
|
||||
| Import | Provides |
|
||||
| --- | --- |
|
||||
| `@joan/procedural-glyph-engine` | `JoanGlyphEngine`, `createGlyph()`, core catalogs, and rendering utilities |
|
||||
| `@joan/procedural-glyph-engine/config` | Strict option inspection, validation, and frozen recipe helpers |
|
||||
| `@joan/procedural-glyph-engine/fields` | Seeded field samplers, noise helpers, and field registration |
|
||||
| `@joan/procedural-glyph-engine/glyphs` | Analytic glyph masks and glyph registration helpers |
|
||||
| `@joan/procedural-glyph-engine/sprites` | Immutable semantic sprite catalog and aliases |
|
||||
| `@joan/procedural-glyph-engine/state-director` | Escalation timing for long-running task presentation |
|
||||
| `@joan/procedural-glyph-engine/state-sequence` | Serializable timelines and controlled timed playback |
|
||||
| `@joan/procedural-glyph-engine/web-component` | `<joan-glyph>` element, definition helper, and automatic browser registration |
|
||||
| `@joan/procedural-glyph-engine/web-component/register` | Explicit side-effect registration for `<joan-glyph>` |
|
||||
| `@joan/procedural-glyph-engine/styles.css` | Default web-component presentation styles |
|
||||
|
||||
### Engine construction
|
||||
|
||||
Create one long-lived engine instance per surface. A more fully authored setup
|
||||
can override the active recipe while preserving the same semantic API:
|
||||
|
||||
```js
|
||||
import { createGlyph } from "@joan/procedural-glyph-engine";
|
||||
|
||||
const glyph = createGlyph("#ai-glyph", {
|
||||
sprite: "ai.idle",
|
||||
seed: "conversation-42",
|
||||
gridSize: 68,
|
||||
pixelShape: "disc",
|
||||
pixelSwitch: "threshold-hysteresis",
|
||||
transition: "field-morph",
|
||||
orbBoundary: "gestalt",
|
||||
orbBackgroundColor: "#14212b",
|
||||
orbBackgroundMode: "pixelated",
|
||||
speed: 1,
|
||||
density: 1,
|
||||
});
|
||||
|
||||
await glyph.transitionTo("ai.thinking", {
|
||||
transition: "neighbor-ignite",
|
||||
duration: 0.5,
|
||||
preservePhase: true,
|
||||
});
|
||||
|
||||
// Release observers, listeners, and the animation frame when unmounting.
|
||||
glyph.destroy();
|
||||
```
|
||||
|
||||
`setSprite()` is synchronous and chainable. `transitionTo()` resolves when the
|
||||
visual transition completes and accepts an `AbortSignal`; use it when product
|
||||
flow must wait for presentation. For a direct, unbundled demo import, replace
|
||||
the package import with `./src/joan-engine.js`.
|
||||
|
||||
`createGlyph(canvasOrSelector, options)` is the preferred factory.
|
||||
`createProceduralGlyph({ canvas, ...options })` and
|
||||
`mountProceduralGlyph(target, options)` provide equivalent construction forms.
|
||||
|
||||
The constructor form is equivalent:
|
||||
|
||||
```js
|
||||
import JoanGlyphEngine from "@joan/procedural-glyph-engine";
|
||||
|
||||
const glyph = new JoanGlyphEngine(canvas, {
|
||||
sprite: "ai.loading",
|
||||
autoplay: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Lifecycle and configuration
|
||||
|
||||
Useful lifecycle and configuration methods include `play()`, `pause()`,
|
||||
`toggle()`, `activate()`, `resume()`, `renderOnce()`, `setSeed()`,
|
||||
`setResolution()`, `setField()`, `setPixelShape()`, `setPixelSwitch()`,
|
||||
`setNonErrorPalette()`, `setOptions()`, `transitionTo()`,
|
||||
`whenTransitionComplete()`, `configure()`, `inspect()`, `exportConfig()`,
|
||||
`toDataURL()`, `toBlob()`,
|
||||
`toSVG()`, `toAnimatedSVG()`, `downloadPNG()`, `downloadSVG()`,
|
||||
`downloadAnimatedSVG()`, and `destroy()`.
|
||||
|
||||
Built-in sprites use layered field stacks. `setField("radar")` deliberately
|
||||
replaces that stack with one field; call `useRecipeFields()` to restore the
|
||||
sprite's authored composition. `exportConfig()` records this distinction as
|
||||
`fieldMode: "recipe" | "override"`, so configurations round-trip faithfully.
|
||||
Recipe-owned pixel geometry, switching, and transitions serialize as `null`;
|
||||
explicit global overrides serialize as their string or structured object. This
|
||||
keeps reconstructed engines on each future state's authored motion recipe.
|
||||
|
||||
### Orb boundary and background
|
||||
|
||||
Set `orbBoundary: "gestalt"` to replace the continuous circular rim with an
|
||||
implied edge built from separated pixel clusters. The default is
|
||||
`orbBoundary: "defined"`, which preserves the authored hard outline. The
|
||||
Gestalt treatment is available for `ai.idle`, `ai.ambient-idle`,
|
||||
`ai.ambient-thinking`, and `ai.ambient-speaking`; other sprites retain their
|
||||
authored silhouette. The option remains configured when moving between states,
|
||||
so one engine can carry the same boundary preference through a product flow.
|
||||
Custom circular recipes can opt in with
|
||||
`composition: { gestaltBoundary: true, gestaltOpenness: 0.5 }`.
|
||||
|
||||
```js
|
||||
glyph.setOptions({ orbBoundary: "gestalt" });
|
||||
glyph.setOptions({ orbBoundary: "defined" }); // restore the continuous rim
|
||||
```
|
||||
|
||||
`orbBackgroundColor` supplies the color for an independent fill behind the
|
||||
pixels of the same four supported presence orbs. Choose its treatment with
|
||||
`orbBackgroundMode`: `"none"` disables the layer, `"solid"` draws a smooth
|
||||
circle, and `"pixelated"` builds the circle from grid-aligned row runs. The
|
||||
pixelated mode follows the selected engine resolution, so its edge belongs to
|
||||
the same grid as the foreground glyph. The fill carries through other states
|
||||
without painting there, remains visible when the canvas background is disabled,
|
||||
and is included in static and animated SVG exports.
|
||||
|
||||
An initial `orbBackgroundColor` without an explicit mode selects `"solid"`.
|
||||
Once `"pixelated"` is selected, later color-only patches
|
||||
preserve that treatment so changing the swatch does not reset the shape.
|
||||
Clearing the color with `null` or `"transparent"` disables the layer. Set the
|
||||
mode explicitly when you want to retain a color while temporarily hiding it.
|
||||
|
||||
```js
|
||||
glyph.setOptions({
|
||||
orbBackgroundColor: "#14212b",
|
||||
orbBackgroundMode: "pixelated",
|
||||
});
|
||||
glyph.setOptions({ orbBackgroundMode: "none" }); // retain the chosen color
|
||||
glyph.setOptions({ orbBackgroundMode: "solid" });
|
||||
glyph.setOptions({ orbBackgroundColor: null }); // disable and clear the color
|
||||
```
|
||||
|
||||
Named options fail fast with a nearby-name suggestion instead of silently
|
||||
falling back. Use the config subpath to validate before constructing an engine,
|
||||
or to define and freeze a custom recipe:
|
||||
|
||||
```js
|
||||
import { getSprite } from "@joan/procedural-glyph-engine";
|
||||
import {
|
||||
defineRecipe,
|
||||
inspectEngineOptions,
|
||||
validateEngineOptions,
|
||||
} from "@joan/procedural-glyph-engine/config";
|
||||
|
||||
const options = validateEngineOptions({
|
||||
sprite: "thinking", // canonicalized to ai.thinking
|
||||
field: "domain_warp", // canonicalized to domain-warp
|
||||
gridSize: 68,
|
||||
quality: "auto",
|
||||
});
|
||||
|
||||
const inspection = inspectEngineOptions(untrustedOptions);
|
||||
if (!inspection.ok) console.table(inspection.issues);
|
||||
|
||||
const branded = defineRecipe({
|
||||
...getSprite("ai.generating"),
|
||||
id: "product.generating",
|
||||
glyph: "product.mark",
|
||||
});
|
||||
```
|
||||
|
||||
`validateEngineOptions()` is strict and non-mutating. `inspectEngineOptions()`
|
||||
returns `{ ok, value, issues }`, safely
|
||||
coerces ordinary HTML-style values, and omits invalid named options.
|
||||
|
||||
### API shape
|
||||
|
||||
| Operation | Return | Use it for |
|
||||
| --- | --- | --- |
|
||||
| `createGlyph(target, options)` | engine | The preferred canvas-or-selector invocation |
|
||||
| `setSprite(sprite, options)` | engine | Immediate, chainable state commands |
|
||||
| `transitionTo(sprite, options)` | `Promise<TransitionDetail>` | Waiting for the visual handoff or cancelling it with `signal` |
|
||||
| `configure(patch)` | engine | Strict, atomic runtime configuration with one consolidated event/render |
|
||||
| `setOptions(patch)` | engine | Runtime configuration with safe coercion |
|
||||
| `signal(type, payload)` | engine | Short-lived product events and semantic inputs |
|
||||
| `on(type, listener)` | unsubscribe function | Typed event subscription with one-call cleanup |
|
||||
| `inspect()` | JSON-safe snapshot | State, transition, signals, palette, quality, and performance debugging |
|
||||
| `renderOnce(time)` | stats | Deterministic paused previews and test fixtures |
|
||||
| `exportConfig()` | JSON-safe object | Reconstructing the authored runtime configuration |
|
||||
| `destroy()` | `undefined` | Releasing frames, observers, and listeners |
|
||||
|
||||
The package declarations expose literal unions for built-in sprites, fields,
|
||||
pixel shapes, switches, transitions, quality settings, event payloads, recipes,
|
||||
exports, the custom element, `StateDirector`, and `StateSequencePlayer`.
|
||||
|
||||
### Color control
|
||||
|
||||
Use `nonErrorPalette` to override any combination of the `background`, `off`,
|
||||
`ink`, `accent`, and `glow` channels for every state except `status.error`.
|
||||
Unspecified channels continue to come from each state’s authored palette.
|
||||
Use `variants` when shared light or dark surfaces need to preserve the authored
|
||||
semantic color family. Variant keys match palette names such as `info`,
|
||||
`success`, `warning`, and `celebration`; explicit top-level channels still win.
|
||||
|
||||
```js
|
||||
const glyph = new JoanGlyphEngine(canvas, {
|
||||
sprite: "ai.thinking",
|
||||
nonErrorPalette: {
|
||||
background: "#e5e6e2",
|
||||
off: "#c7cbc8",
|
||||
ink: "#17191e",
|
||||
variants: {
|
||||
info: { accent: "#4788aa", glow: "#6aa6c4" },
|
||||
success: { accent: "#397c57", glow: "#62a27b" },
|
||||
warning: { accent: "#9a6817", glow: "#bd8b3c" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
glyph.setNonErrorPalette({ ink: "#ffffff", accent: "#53d6c7" });
|
||||
glyph.setNonErrorPalette({ glow: "transparent" }); // disable the halo
|
||||
glyph.setNonErrorPalette(null); // restore authored state colors
|
||||
```
|
||||
|
||||
The error state keeps its danger palette so a product-level color choice cannot
|
||||
erase its failure semantics. The `palette` option provides an explicit global
|
||||
override—including for errors—when that behavior is needed.
|
||||
Hex and `rgb()` color values provide consistent Canvas and SVG output.
|
||||
|
||||
In the studio, turn **Glow** off to select no glow color. The previous chosen
|
||||
color is preserved and returns when Glow is enabled again.
|
||||
|
||||
## Web component
|
||||
|
||||
Importing the web-component subpath registers `<joan-glyph>` in a browser and
|
||||
is safe to evaluate during SSR:
|
||||
|
||||
```js
|
||||
import "@joan/procedural-glyph-engine/web-component";
|
||||
```
|
||||
|
||||
An explicit side-effect entry is also available for application bootstrap:
|
||||
|
||||
```js
|
||||
import "@joan/procedural-glyph-engine/web-component/register";
|
||||
```
|
||||
|
||||
```html
|
||||
<joan-glyph
|
||||
sprite="ai.generating"
|
||||
seed="answer-108"
|
||||
resolution="68"
|
||||
speed="0.9"
|
||||
density="1"
|
||||
field="electric"
|
||||
pixel-shape="diamond"
|
||||
pixel-switch="curl-advect"
|
||||
orb-boundary="gestalt"
|
||||
orb-background-color="#14212b"
|
||||
orb-background-mode="pixelated"
|
||||
non-error-background="#10131a"
|
||||
non-error-off="#293140"
|
||||
non-error-ink="#f7f8fb"
|
||||
non-error-accent="#8f7cff"
|
||||
non-error-glow="#6554e8"
|
||||
></joan-glyph>
|
||||
```
|
||||
|
||||
Size the host with CSS; its default size is `68px × 68px`.
|
||||
|
||||
```css
|
||||
joan-glyph {
|
||||
inline-size: 3rem;
|
||||
block-size: 3rem;
|
||||
}
|
||||
```
|
||||
|
||||
The five `non-error-*` color attributes are optional and may be added, changed,
|
||||
or removed at runtime. The boolean `paused` attribute disables autoplay.
|
||||
`noninteractive` disables pointer interaction and `transparent` disables the
|
||||
painted background; both react when added or removed. Removing `field`,
|
||||
`pixel-shape`, or `pixel-switch` restores the recipe-authored behavior. The
|
||||
optional `orb-boundary` attribute accepts `gestalt`; removing it (or using an
|
||||
unsupported value) restores the default `defined` boundary. The optional
|
||||
`orb-background-color` attribute adds the orb-only fill; removing it clears the
|
||||
fill without changing the canvas background. Use `orb-background-mode="none"`,
|
||||
`"solid"`, or `"pixelated"` to choose the treatment. Supplying only
|
||||
`orb-background-color` selects `solid`; removing the mode attribute returns to
|
||||
that color-driven behavior. The
|
||||
element forwards the engine's state, signal, palette, playback, configuration,
|
||||
and inspection methods and exposes the underlying instance as `element.engine`.
|
||||
|
||||
## Custom glyphs
|
||||
|
||||
A sampler receives normalized `x`, `y`, animation time, and the live engine
|
||||
context. Return coverage from `0` (off) to `1` (fully covered). Keep the hot
|
||||
sampler pure and allocation-free.
|
||||
|
||||
```js
|
||||
import {
|
||||
createProceduralGlyph,
|
||||
getSprite,
|
||||
} from "@joan/procedural-glyph-engine";
|
||||
|
||||
const glyph = createProceduralGlyph({ canvas, autoplay: true });
|
||||
|
||||
glyph.registerGlyph("product.spark", (x, y, time, context) => {
|
||||
const radius = Math.hypot(x, y);
|
||||
const spokes = Math.cos(Math.atan2(y, x) * 8 + time * 0.6);
|
||||
const pulse = 0.04 * Math.sin(time * 1.4 + context.energy * Math.PI);
|
||||
return radius < 0.42 + spokes * 0.08 + pulse ? 1 : 0;
|
||||
});
|
||||
|
||||
const base = getSprite("ai.generating");
|
||||
glyph.setSprite({
|
||||
...base,
|
||||
id: "product.spark",
|
||||
label: "Generating with Product",
|
||||
glyph: "product.spark",
|
||||
semantic: {
|
||||
...base.semantic,
|
||||
category: "custom",
|
||||
meaning: "generating branded output",
|
||||
},
|
||||
labels: {
|
||||
...base.labels,
|
||||
default: "Generating with Product",
|
||||
aria: "Generating branded output",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
A two-dimensional numeric array is accepted as a bitmap and its dimensions are
|
||||
inferred:
|
||||
|
||||
```js
|
||||
glyph.registerGlyph("product.pixel-heart", [
|
||||
[0, 1, 0, 1, 0],
|
||||
[1, 1, 1, 1, 1],
|
||||
[1, 1, 1, 1, 1],
|
||||
[0, 1, 1, 1, 0],
|
||||
[0, 0, 1, 0, 0],
|
||||
]);
|
||||
```
|
||||
|
||||
In the browser, `loadGlyphFile(file, options)` rasterizes an image file and
|
||||
activates it as a custom glyph:
|
||||
|
||||
```js
|
||||
await glyph.loadGlyphFile(fileInput.files[0], {
|
||||
id: "product.uploaded-mark",
|
||||
label: "Product mark",
|
||||
baseSprite: "ai.generating",
|
||||
resolution: 48,
|
||||
});
|
||||
```
|
||||
|
||||
## Signals, audio, and progress
|
||||
|
||||
Signals add short-lived spatial energy without changing semantic state. Signal
|
||||
names are intentionally open-ended, so the host can mirror its own event model.
|
||||
|
||||
```js
|
||||
glyph.signal("token", { energy: 0.55 });
|
||||
glyph.signal("search.hit", { x: 0.35, y: -0.2, energy: 0.9, life: 0.8 });
|
||||
glyph.signal("tool.call", { energy: 1 });
|
||||
glyph.signal("audio.level", { value: microphoneLevel });
|
||||
glyph.signal("transfer.direction", { direction: "up" });
|
||||
glyph.signal("handoff.accepted", { accepted: true });
|
||||
glyph.signal("network.retry", { energy: 0.8 });
|
||||
glyph.signal("resume", { value: 1 }); // restores the state held before status.paused
|
||||
```
|
||||
|
||||
`audio.level` updates the smoothed listening/speaking input. `progress` is
|
||||
special-cased as determinate state:
|
||||
|
||||
```js
|
||||
glyph.setSprite("ai.progress");
|
||||
glyph.setProgress(0.42); // clamped to 0..1
|
||||
|
||||
// Equivalent low-level form:
|
||||
glyph.signal("progress", { value: 0.42 });
|
||||
```
|
||||
|
||||
`transfer.active` also consumes progress. Register a direction-specific custom
|
||||
glyph if the product must distinguish upload from download visually.
|
||||
|
||||
The engine dispatches `spritechange`, `transitionqueued`, `transitioncomplete`,
|
||||
`timelinecomplete`, `resume`, `configchange`, `signal`, `activate`, `play`,
|
||||
`pause`, `qualitychange`, `stats`, and `destroy` events:
|
||||
|
||||
```js
|
||||
glyph.addEventListener("spritechange", ({ detail }) => {
|
||||
console.log(`${detail.from} → ${detail.to}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Export
|
||||
|
||||
`downloadPNG()` captures the current canvas. `toSVG()` / `downloadSVG()` create
|
||||
a vector snapshot using the selected pixel geometry. `toAnimatedSVG()` /
|
||||
`downloadAnimatedSVG()` simulate an isolated deterministic clone, sample its
|
||||
pixel gates, and encode shape, opacity, and scale frames without changing the
|
||||
live engine.
|
||||
|
||||
```js
|
||||
await glyph.downloadPNG("thinking.png");
|
||||
await glyph.downloadSVG("thinking.svg");
|
||||
await glyph.downloadAnimatedSVG("thinking.animated.svg", {
|
||||
duration: 2.4,
|
||||
fps: 12,
|
||||
maxGridSize: 48,
|
||||
});
|
||||
```
|
||||
|
||||
Animated export is intentionally capped and yields between frame batches to
|
||||
keep the studio responsive. Raise `fps`, duration, or `maxGridSize` only after
|
||||
checking file size and export time.
|
||||
|
||||
## Timed and pre-recorded state sequences
|
||||
|
||||
`StateSequencePlayer` turns product-owned state choreography into a small,
|
||||
reusable API. Definitions are frozen and JSON-friendly, so a host can keep them
|
||||
beside an agent workflow, load them from its own configuration, or construct a
|
||||
one-off sequence at the point of use.
|
||||
|
||||
```js
|
||||
import {
|
||||
StateSequencePlayer,
|
||||
defineStateSequence,
|
||||
} from "@joan/procedural-glyph-engine/state-sequence";
|
||||
|
||||
const idleThenDone = defineStateSequence("idle-then-done", [
|
||||
{ sprite: "ai.idle", holdMs: 5_000 },
|
||||
{ sprite: "status.success", transition: "path-draw" },
|
||||
]);
|
||||
|
||||
const states = new StateSequencePlayer(glyph, {
|
||||
sequences: [idleThenDone],
|
||||
transition: "field-morph",
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const result = await states.play("idle-then-done", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
```
|
||||
|
||||
The first step is entered immediately by default. Each later step waits for its
|
||||
visual transition to complete, then holds for `holdMs` before advancing.
|
||||
`durationMs` is accepted as an alias for `holdMs`; transition `duration` values
|
||||
continue to use the engine's seconds-based API.
|
||||
|
||||
For an inline command, `playStateSequence()` starts immediately and returns the
|
||||
playback controller:
|
||||
|
||||
```js
|
||||
import { playStateSequence } from
|
||||
"@joan/procedural-glyph-engine/state-sequence";
|
||||
|
||||
const playback = playStateSequence(glyph, [
|
||||
{ sprite: "ai.ambient-idle", holdMs: 1_500 },
|
||||
{ sprite: "ai.progress", transition: "contour-trace" },
|
||||
]);
|
||||
|
||||
playback.pause(); // freezes the current hold clock
|
||||
playback.resume();
|
||||
await playback.finished;
|
||||
```
|
||||
|
||||
Call `stop()` for an intentional early finish, or pass an `AbortSignal` when
|
||||
the surrounding task owns cancellation. Aborts reject with `AbortError`;
|
||||
`stop()` resolves with a `stopped` result. Starting another playback safely
|
||||
stops the active run and starts the new one. Pausing freezes the hold clock and
|
||||
step progression;
|
||||
an already-running visual transition continues to settle. `sequencestart`,
|
||||
`stepstart`, `statechange`, `stepenter`,
|
||||
`stepcomplete`, `sequencepause`, `sequenceresume`, `sequencestop`,
|
||||
`sequencecancel`, `sequencecomplete`, and `sequenceerror` events expose the
|
||||
full lifecycle. Completed, stopped, cancelled, and destroyed players clear
|
||||
their timeout and abort listeners.
|
||||
|
||||
## StateDirector
|
||||
|
||||
`StateDirector` is an optional presentation timer. The host still owns the real
|
||||
task state; the director never guesses whether work started, succeeded, or
|
||||
failed.
|
||||
|
||||
```js
|
||||
import { StateDirector } from
|
||||
"@joan/procedural-glyph-engine/state-director";
|
||||
|
||||
const director = new StateDirector(glyph, {
|
||||
deepThinkingAfterMs: 7_000,
|
||||
stillWorkingAfterMs: 18_000,
|
||||
transition: "field-morph",
|
||||
});
|
||||
|
||||
director.beginThinking();
|
||||
|
||||
try {
|
||||
await performWork();
|
||||
director.complete(); // status.success + a completion impulse
|
||||
} catch (error) {
|
||||
director.fail(); // status.error
|
||||
}
|
||||
|
||||
// Return to a clean phase for the next operation.
|
||||
director.reset();
|
||||
|
||||
// Clear pending escalation timers when the owner unmounts.
|
||||
director.destroy();
|
||||
glyph.destroy();
|
||||
```
|
||||
|
||||
`beginThinking()` moves from `ai.thinking` to `ai.thinking-deep`, then to
|
||||
`ai.still-working` at the configured thresholds. `set()`, `complete()`,
|
||||
`fail()`, or `reset()` cancels pending timers.
|
||||
|
||||
For the common one-task lifecycle, `run()` removes the surrounding state
|
||||
boilerplate and rethrows failures after presenting them:
|
||||
|
||||
```js
|
||||
const controller = new AbortController();
|
||||
|
||||
const result = await director.run(
|
||||
({ signal }) => performWork({ signal }),
|
||||
{
|
||||
signal: controller.signal,
|
||||
deepThinkingAfterMs: 7_000,
|
||||
stillWorkingAfterMs: 18_000,
|
||||
successSprite: "status.success",
|
||||
errorSprite: "status.error",
|
||||
cancelledSprite: "status.cancelled",
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Starting another director operation prevents earlier asynchronous work from
|
||||
overwriting its state. Aborting moves to `status.cancelled` and rejects with an
|
||||
`AbortError`. Every manual, escalated, successful, failed, cancelled, and reset
|
||||
state dispatches `statechange` with `{ state, from, to, reason }`.
|
||||
|
||||
## Studio preset library
|
||||
|
||||
`Save preset` and `Save current` add the tuned recipe to the visible **Saved
|
||||
presets** tile library above the State Atlas. The Studio keeps up to 24 entries
|
||||
in this browser's local storage under `joan.studio.recent-presets.v1`; they do
|
||||
not sync to another browser or device. A tile restores its recipe, Delete has
|
||||
an immediate Undo action, and **Prepare recipe** creates a portable `.joan.json`
|
||||
file when a preset must move beyond browser-local Studio state.
|
||||
|
||||
## Accessibility and reduced motion
|
||||
|
||||
- The engine assigns the canvas `role="img"` and updates its accessible label
|
||||
when interaction is disabled. Interactive canvases use button semantics,
|
||||
support Enter and Space activation, and expose a visible focus treatment.
|
||||
Pass an `ariaLive` element when state changes should be announced.
|
||||
- Keep a visible text status beside the glyph. Do not communicate success,
|
||||
warning, failure, progress, or waiting through motion or color alone.
|
||||
- Avoid duplicate announcements: use `ariaLive` only if the surrounding product
|
||||
does not already announce the same state.
|
||||
- `reducedMotion: "system"` is the default. It follows
|
||||
`prefers-reduced-motion`, pauses continuous autoplay, renders representative
|
||||
glyph phases, and uses 100–120 ms transitions for the built-in recipes;
|
||||
explicit reduced-motion durations remain capped at 200 ms.
|
||||
- Set `reducedMotion: true` to force the static behavior or `false` only when the
|
||||
product has a deliberate, user-controlled motion policy.
|
||||
- Semantic recipes include a reduced-motion representation and use palettes
|
||||
designed to remain understandable in monochrome, but application-level
|
||||
contrast and surrounding copy still need product accessibility review.
|
||||
- Call `setProgress()` even in reduced motion. Data changes remain meaningful
|
||||
when spatial animation is removed.
|
||||
|
||||
## Development, test, and build
|
||||
|
||||
Requirements: Node.js 18 or newer; Node.js 20 is the tested handoff target.
|
||||
|
||||
> [!NOTE]
|
||||
> The commands in this section require a repository clone. The downloadable
|
||||
> integration kit intentionally includes runtime sources, types, documentation,
|
||||
> examples, and the offline Studio—not the project build scripts or test suite.
|
||||
|
||||
```sh
|
||||
# Run the local studio at http://127.0.0.1:4173
|
||||
npm run dev
|
||||
|
||||
# Run dependency-free node:test contract tests
|
||||
npm test
|
||||
|
||||
# Recreate dist/, the standalone HTML build, and the website download kit
|
||||
npm run build
|
||||
|
||||
# Run tests, build, then verify the complete download artifact
|
||||
npm run check
|
||||
|
||||
# Validate, build, and create the installable .tgz package
|
||||
npm pack
|
||||
```
|
||||
|
||||
`npm pack` is suitable for local integration testing. The resulting package
|
||||
includes `LICENSE` and `NOTICE` and carries the same source-available terms as
|
||||
the repository.
|
||||
|
||||
Set `JOAN_ENGINE_PORT` to use a different development port:
|
||||
|
||||
```sh
|
||||
JOAN_ENGINE_PORT=4400 npm run dev
|
||||
```
|
||||
|
||||
The studio preview defaults to **Fit**. Switch it to **1:1** to center the glyph
|
||||
at its actual grid footprint, with one engine cell mapped to one CSS pixel. This
|
||||
is a studio-only inspection view and does not alter exported recipe configuration.
|
||||
|
||||
The build copies the native module sources and studio assets into `dist/`,
|
||||
creates the Sites-compatible `dist/client` and `dist/server/index.js` outputs,
|
||||
generates the versioned ZIP, checksums, and release manifest in `downloads/`,
|
||||
and creates `joan-engine-v5.standalone.html`. Commit source files rather than
|
||||
editing generated output.
|
||||
|
||||
## Performance guidance
|
||||
|
||||
Rendering cost grows approximately with `gridSize²`. The runtime defaults to
|
||||
68×68 and uses adaptive quality to hold its frame budget. Override that with
|
||||
24–36 for compact product icons or dense multi-glyph surfaces.
|
||||
|
||||
- Reuse an engine and call `setSprite()`; do not construct an engine for every
|
||||
state change.
|
||||
- For thumbnail grids, set `autoplay: false`, `autoResize: false`,
|
||||
`interactive: false`, `quality: "low"`, `dprMax: 1`, and call `renderOnce()`
|
||||
only when a preview needs updating.
|
||||
- Use `fps: 24` or `30` for ambient UI. Reserve 60 fps for close, interactive
|
||||
motion.
|
||||
- Set `offPixels: false` to remove the background-dot draw pass. Disable the
|
||||
painted background with `background: false` when the product surface already
|
||||
supplies one.
|
||||
- `quality: "auto"` adapts between `high`, `balanced`, and `low` from measured
|
||||
frame cost. Read the effective tier from `stats.quality` or subscribe to
|
||||
`qualitychange`. Balanced and low tiers time-slice field sampling across the
|
||||
grid, and low also skips the glow path. Use a fixed high tier or
|
||||
`renderOnce()` for full-grid deterministic visual fixtures.
|
||||
- Cap device-pixel work with `dprMax`; a value of `1` is often enough for
|
||||
pixel-art thumbnails and dense dashboards.
|
||||
- Disable pointer work with `interactive: false` for decorative or
|
||||
noninteractive instances.
|
||||
- Keep custom sampler functions allocation-free and avoid DOM reads, object
|
||||
creation, and network/state access inside them.
|
||||
- Let the built-in visibility observation cancel animation-frame scheduling for
|
||||
hidden and off-screen canvases; it resumes automatically when visible. Always
|
||||
call `destroy()` when removing an instance.
|
||||
- Watch the `stats` event (`fps`, `frameMs`, `sampledPixels`, `activePixels`, and `resolution`) in
|
||||
realistic multi-glyph screens, not just an isolated demo.
|
||||
|
||||
For deterministic visual regression fixtures, use `autoplay: false`, disable
|
||||
interaction and auto-resize, set a fixed canvas size and seed, then call
|
||||
`renderOnce(fixedTime)`.
|
||||
|
||||
## License
|
||||
|
||||
Orby is source-available under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
subject to the [Commons Clause License Condition v1.0](https://commonsclause.com/).
|
||||
|
||||
- You may use, copy, modify, and redistribute Orby, and include it in a larger
|
||||
value-added product, subject to the full terms.
|
||||
- You may not sell Orby or offer a product or service whose value derives
|
||||
entirely or substantially from Orby's functionality.
|
||||
- Keep the required license and attribution notices when redistributing it.
|
||||
|
||||
Because it restricts selling, this is a **source-available** license rather than
|
||||
an OSI-approved open-source license. Read [`LICENSE`](./LICENSE) for the complete
|
||||
terms and [`NOTICE`](./NOTICE) for attribution.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://joansterjo-celonis.github.io/Procedural-glyph-engine/">Live Studio</a>
|
||||
·
|
||||
<a href="https://joansterjo-celonis.github.io/Procedural-glyph-engine/#download">Download kit</a>
|
||||
·
|
||||
<a href="#orby">Back to top</a>
|
||||
</p>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user