From 35b3ff338e84813cd9c8dc103c984ea8e95fe45a Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Tue, 17 Feb 2026 14:37:46 +0100 Subject: [PATCH] Fix 9p-read-inbox to strip .json extension when marking messages complete --- .gitignore | 6 +- cfg/roles/developer.yaml | 2 + claude/agent/anvillm-agent.md | 61 ++++++++++++++++ claude/hooks/anvillm-state-idle.sh | 6 ++ cmd/Assist/main.go | 30 +++++++- cmd/anvillm/main.go | 12 +++ cmd/anvilweb/main.go | 40 ++++++++++ elisp/anvillm.el | 17 ++++- internal/backend/tmux/session.go | 15 ++++ internal/mailbox/manager.go | 36 +++++++++ internal/p9/server.go | 113 +++++++++++++++++++++++++++-- internal/session/manager.go | 48 ++++-------- kiro-cli/agent/anvillm-agent.json | 2 +- mkfile | 2 + scripts/9p-read-inbox | 50 +++++++++++++ skills/agent-kb/SKILL.md | 24 +++++- 16 files changed, 413 insertions(+), 51 deletions(-) create mode 100755 scripts/9p-read-inbox diff --git a/.gitignore b/.gitignore index 22b4ee7..c284b8c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,9 @@ Prompt Q-Reset Q-Login /anvillm -anvilweb +/anvilweb .claude .kiro *.elc -Assist -anvilsrv +/Assist +/anvilsrv diff --git a/cfg/roles/developer.yaml b/cfg/roles/developer.yaml index c6734f2..7f3525e 100644 --- a/cfg/roles/developer.yaml +++ b/cfg/roles/developer.yaml @@ -1,5 +1,7 @@ # Developer role configuration filesystem: + ro: + - "{HOME}/doc" rw: - "{CWD}" rwx: diff --git a/claude/agent/anvillm-agent.md b/claude/agent/anvillm-agent.md index 401fbcf..a6bfb40 100644 --- a/claude/agent/anvillm-agent.md +++ b/claude/agent/anvillm-agent.md @@ -5,3 +5,64 @@ permissionMode: bypassPermissions --- You are an AI assistant for AnviLLM multi-agent workflows. You have full access to all tools and operate without permission restrictions. + +## Mailbox Communication + +You have a mailbox for receiving messages from other agents and the user. Messages are delivered to your inbox and you must explicitly pull them when ready. + +### Checking Your Inbox + +Your inbox is accessible via the `agent` namespace. Check it using the helper script: + +```bash +# Check for messages +9p-read-inbox + +# Read and mark complete in one step +9p-read-inbox --complete +``` + +Or manually: + +```bash +# Get your agent ID +AGENT_ID=$(9p ls agent/ | head -1) + +# List pending messages +9p ls agent/$AGENT_ID/inbox/ + +# Read first message +MSG=$(9p ls agent/$AGENT_ID/inbox/ | head -1) +9p read agent/$AGENT_ID/inbox/$MSG + +# After processing, mark complete +9p rm agent/$AGENT_ID/inbox/$MSG +``` + +### When to Check + +- When transitioning to idle state (after completing a task) +- When explicitly told to check messages +- Periodically during long-running tasks + +### Message Format + +Messages are JSON with these fields: +- `from`: Sender's session ID or "user" +- `to`: Your session ID +- `type`: Message type (QUESTION, REVIEW_REQUEST, STATUS_UPDATE, etc.) +- `subject`: Brief subject line +- `body`: Message content +- `id`: Unique message ID +- `timestamp`: When message was sent + +### Sending Messages + +To send a message to another agent or user: + +```bash +cat > /tmp/msg.json <<'ENDMSG' +{"to":"recipient-id","type":"MESSAGE_TYPE","subject":"Subject","body":"Message body"} +ENDMSG +9p write agent/YOUR_ID/outbox/msg-$(date +%s).json < /tmp/msg.json +``` diff --git a/claude/hooks/anvillm-state-idle.sh b/claude/hooks/anvillm-state-idle.sh index 85b6601..fd45077 100644 --- a/claude/hooks/anvillm-state-idle.sh +++ b/claude/hooks/anvillm-state-idle.sh @@ -26,6 +26,12 @@ fi echo "$(date '+%Y-%m-%d %H:%M:%S') - Found session ID: $session" >> /tmp/claude-hooks.log if echo idle | 9p write agent/$session/state 2>>/tmp/claude-hooks.log; then echo "$(date '+%Y-%m-%d %H:%M:%S') - SUCCESS: Wrote 'idle' to agent/$session/state" >> /tmp/claude-hooks.log + + # Check for pending messages in inbox + if [ -n "$(9p ls agent/$session/inbox/ 2>/dev/null)" ]; then + echo "$(date '+%Y-%m-%d %H:%M:%S') - INBOX: Pending messages detected" >> /tmp/claude-hooks.log + echo "[INBOX] You have pending messages. Check with: 9p-read-inbox" + fi else echo "$(date '+%Y-%m-%d %H:%M:%S') - ERROR: Failed to write to agent/$session/state" >> /tmp/claude-hooks.log fi diff --git a/cmd/Assist/main.go b/cmd/Assist/main.go index 5b6b1d8..065df71 100644 --- a/cmd/Assist/main.go +++ b/cmd/Assist/main.go @@ -387,6 +387,21 @@ func sendPrompt(id, prompt string) error { return nil } +func sendPromptDirect(id, prompt string) error { + if !isConnected() { + return fmt.Errorf("not connected to anvilsrv") + } + path := filepath.Join(id, "in") + fid, err := fs.Open(path, plan9.OWRITE) + if err != nil { + return err + } + defer fid.Close() + + _, err = fid.Write([]byte(prompt)) + return err +} + func setAlias(id, alias string) error { if !isConnected() { return fmt.Errorf("not connected to anvilsrv") @@ -563,7 +578,7 @@ func openPromptWindow(sess *SessionInfo) (*acme.Win, error) { return nil, err } w.Name(name) - w.Write("tag", []byte("Send ")) + w.Write("tag", []byte("Prompt Message ")) w.Ctl("clean") // Track window ID client-side @@ -584,15 +599,22 @@ func handlePromptWindow(w *acme.Win, sess *SessionInfo) { }() for e := range w.EventChan() { - if (e.C2 == 'x' || e.C2 == 'X') && string(e.Text) == "Send" { + cmd := string(e.Text) + if (e.C2 == 'x' || e.C2 == 'X') && (cmd == "Prompt" || cmd == "Message") { body, err := w.ReadAll("body") if err != nil { continue } prompt := strings.TrimSpace(string(body)) if prompt != "" { - if err := sendPrompt(sess.ID, prompt); err != nil { - fmt.Fprintf(os.Stderr, "Failed to send prompt: %v\n", err) + var sendErr error + if cmd == "Prompt" { + sendErr = sendPromptDirect(sess.ID, prompt) + } else { + sendErr = sendPrompt(sess.ID, prompt) + } + if sendErr != nil { + fmt.Fprintf(os.Stderr, "Failed to send %s: %v\n", strings.ToLower(cmd), sendErr) continue } w.Ctl("delete") diff --git a/cmd/anvillm/main.go b/cmd/anvillm/main.go index bde13e3..f8f1962 100644 --- a/cmd/anvillm/main.go +++ b/cmd/anvillm/main.go @@ -377,6 +377,18 @@ func sendPrompt(id, prompt string) error { return nil } +func sendPromptDirect(id, prompt string) error { + path := filepath.Join(id, "in") + fid, err := fs.Open(path, plan9.OWRITE) + if err != nil { + return err + } + defer fid.Close() + + _, err = fid.Write([]byte(prompt)) + return err +} + func updateStatus(msg string) { statusBar.Clear() fmt.Fprintf(statusBar, " %s", msg) diff --git a/cmd/anvilweb/main.go b/cmd/anvilweb/main.go index 6749dc5..5489da2 100644 --- a/cmd/anvilweb/main.go +++ b/cmd/anvilweb/main.go @@ -203,6 +203,8 @@ func handleSession(w http.ResponseWriter, r *http.Request) { case "POST": if action == "prompt" { sendPrompt(w, r, id) + } else if action == "prompt-direct" { + sendPromptDirect(w, r, id) } else if action == "ctl" { controlSession(w, r, id) } else { @@ -373,6 +375,44 @@ func sendPrompt(w http.ResponseWriter, r *http.Request, id string) { w.WriteHeader(http.StatusOK) } +func sendPromptDirect(w http.ResponseWriter, r *http.Request, id string) { + var req struct { + Prompt string `json:"prompt"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Prompt) == "" { + http.Error(w, "Prompt cannot be empty", http.StatusBadRequest) + return + } + + fs, err := connect() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer fs.Close() + + path := filepath.Join(id, "in") + fid, err := fs.Open(path, plan9.OWRITE) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + + if _, err := fid.Write([]byte(req.Prompt)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + func controlSession(w http.ResponseWriter, r *http.Request, id string) { var req struct { Command string `json:"command"` diff --git a/elisp/anvillm.el b/elisp/anvillm.el index 9de3a04..8c4a1f5 100644 --- a/elisp/anvillm.el +++ b/elisp/anvillm.el @@ -183,7 +183,7 @@ Format: id alias state pid cwd (whitespace-separated; often tabs)." entry)) (defun anvillm-send-prompt-minibuffer () - "Send a prompt to the selected session." + "Send a prompt to the selected session via mailbox (queued)." (interactive) (if-let ((session-id (anvillm--get-selected-session))) (let ((prompt (read-string "Prompt: "))) @@ -204,6 +204,20 @@ Format: id alias state pid cwd (whitespace-separated; often tabs)." (message "Failed to send prompt: %s" (error-message-string err)))))) (message "No session selected"))) +(defun anvillm-send-prompt-direct () + "Send a prompt directly to the selected session (immediate, may block)." + (interactive) + (if-let ((session-id (anvillm--get-selected-session))) + (let ((prompt (read-string "Prompt (direct): "))) + (when (> (length prompt) 0) + (condition-case err + (progn + (anvillm--9p-write (concat anvillm-agent-path "/" session-id "/in") prompt) + (message "Sent direct prompt to %s" (substring session-id 0 (min 8 (length session-id))))) + (error + (message "Failed to send direct prompt: %s" (error-message-string err)))))) + (message "No session selected"))) + (defun anvillm-stop-session () "Stop the selected session." (interactive) @@ -315,6 +329,7 @@ Backends: (set-keymap-parent map tabulated-list-mode-map) (define-key map (kbd "s") #'anvillm-start-session) (define-key map (kbd "P") #'anvillm-send-prompt-minibuffer) + (define-key map (kbd "M-P") #'anvillm-send-prompt-direct) (define-key map (kbd "t") #'anvillm-stop-session) (define-key map (kbd "R") #'anvillm-restart-session) (define-key map (kbd "K") #'anvillm-kill-session) diff --git a/internal/backend/tmux/session.go b/internal/backend/tmux/session.go index 8a45b40..df8c508 100644 --- a/internal/backend/tmux/session.go +++ b/internal/backend/tmux/session.go @@ -57,6 +57,7 @@ type Session struct { // State machine idleCond *sync.Cond // Signals when state transitions to idle + idleSince time.Time // Timestamp when session last transitioned to idle mu sync.Mutex } @@ -95,11 +96,23 @@ func (s *Session) TransitionTo(newState string) error { return s.transitionToLocked(newState) } +// IdleDuration returns how long the session has been idle. +// Returns 0 if not currently idle. +func (s *Session) IdleDuration() time.Duration { + s.mu.Lock() + defer s.mu.Unlock() + if s.state != "idle" || s.idleSince.IsZero() { + return 0 + } + return time.Since(s.idleSince) +} + func (s *Session) transitionToLocked(newState string) error { // Validate transition switch { case s.state == "starting" && newState == "idle": s.state = newState + s.idleSince = time.Now() s.idleCond.Broadcast() return nil case s.state == "starting" && newState == "error": @@ -110,6 +123,7 @@ func (s *Session) transitionToLocked(newState string) error { return nil case s.state == "running" && newState == "idle": s.state = newState + s.idleSince = time.Now() s.idleCond.Broadcast() return nil case s.state == "running" && newState == "error": @@ -119,6 +133,7 @@ func (s *Session) transitionToLocked(newState string) error { case s.state == "error" && newState == "idle": // Manual recovery from error state s.state = newState + s.idleSince = time.Now() s.idleCond.Broadcast() return nil case s.state == "error" && newState == "starting": diff --git a/internal/mailbox/manager.go b/internal/mailbox/manager.go index 839617a..7947afe 100644 --- a/internal/mailbox/manager.go +++ b/internal/mailbox/manager.go @@ -138,6 +138,42 @@ func (m *Manager) GetPendingMessages(sessionID string) ([]*Message, error) { return m.GetInbox(sessionID), nil } +// PullMessage retrieves and removes the first message from inbox +func (m *Manager) PullMessage(sessionID string) (*Message, error) { + m.mu.Lock() + defer m.mu.Unlock() + + msgs := m.inboxes[sessionID] + if len(msgs) == 0 { + return nil, fmt.Errorf("no messages in inbox") + } + + msg := msgs[0] + m.inboxes[sessionID] = msgs[1:] + return msg, nil +} + +// PeekInbox returns first message without removing it +func (m *Manager) PeekInbox(sessionID string) (*Message, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + msgs := m.inboxes[sessionID] + if len(msgs) == 0 { + return nil, fmt.Errorf("no messages in inbox") + } + + return msgs[0], nil +} + +// HasPendingMessages checks if inbox has messages +func (m *Manager) HasPendingMessages(sessionID string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + + return len(m.inboxes[sessionID]) > 0 +} + // CompleteMessage moves a message from inbox to completed func (m *Manager) CompleteMessage(sessionID, msgID string) error { m.mu.Lock() diff --git a/internal/p9/server.go b/internal/p9/server.go index 61d4bb4..5500e0a 100644 --- a/internal/p9/server.go +++ b/internal/p9/server.go @@ -62,6 +62,7 @@ const ( qidRoot = iota qidCtl qidList + qidStatus qidUser // user directory qidUserInbox // user/inbox qidUserOutbox // user/outbox @@ -106,8 +107,9 @@ type Server struct { } type connState struct { - fids map[uint32]*fid - mu sync.RWMutex + fids map[uint32]*fid + mu sync.RWMutex + sessionID string // Track which session owns this connection } type fid struct { @@ -203,6 +205,8 @@ func (s *Server) handle(cs *connState, fc *plan9.Fcall) *plan9.Fcall { return s.read(cs, fc) case plan9.Twrite: return s.write(cs, fc) + case plan9.Tremove: + return s.remove(cs, fc) case plan9.Tstat: return s.stat(cs, fc) case plan9.Tclunk: @@ -507,7 +511,7 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { } args := strings.Fields(input) if len(args) == 0 { - return errFcall(fc, "usage: stop | restart | kill | refresh") + return errFcall(fc, "usage: stop | restart | kill | refresh | complete ") } switch args[0] { case "stop": @@ -528,6 +532,15 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { if err := sess.Refresh(ctx); err != nil { return errFcall(fc, err.Error()) } + case "complete": + if len(args) < 2 { + return errFcall(fc, "usage: complete ") + } + msgID := args[1] + mailMgr := s.mgr.GetMailManager() + if err := mailMgr.CompleteMessage(parts[0], msgID); err != nil { + return errFcall(fc, err.Error()) + } default: return errFcall(fc, "unknown command") } @@ -536,10 +549,12 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { // /{id}/in - send prompt (async, non-blocking) if len(parts) == 2 && parts[1] == "in" { - sess := s.mgr.Get(parts[0]) + sessID := parts[0] + sess := s.mgr.Get(sessID) if sess == nil { return errFcall(fc, "session not found") } + ctx := context.Background() if _, err := sess.Send(ctx, input); err != nil { return errFcall(fc, err.Error()) @@ -593,7 +608,8 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { // /{id}/state - set session state (with validation) if len(parts) == 2 && parts[1] == "state" { - sess := s.mgr.Get(parts[0]) + sessID := parts[0] + sess := s.mgr.Get(sessID) if sess == nil { return errFcall(fc, "session not found") } @@ -617,6 +633,14 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { return errFcall(fc, fmt.Sprintf("invalid state transition: %v", err)) } } + + // Track this connection's session ID + cs.mu.Lock() + if cs.sessionID == "" { + cs.sessionID = sessID + } + cs.mu.Unlock() + return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} } @@ -624,6 +648,13 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { if len(parts) == 3 && parts[1] == "outbox" && strings.HasSuffix(parts[2], ".json") { sessID := parts[0] + // Track this connection's session ID + cs.mu.Lock() + if cs.sessionID == "" { + cs.sessionID = sessID + } + cs.mu.Unlock() + // Parse JSON message msg, err := mailbox.FromJSON(fc.Data) if err != nil { @@ -691,6 +722,45 @@ func (s *Server) stat(cs *connState, fc *plan9.Fcall) *plan9.Fcall { return &plan9.Fcall{Type: plan9.Rstat, Tag: fc.Tag, Stat: stat} } +func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall { + cs.mu.Lock() + f, ok := cs.fids[fc.Fid] + sessionID := cs.sessionID + cs.mu.Unlock() + if !ok { + return errFcall(fc, "bad fid") + } + + // Only support removing inbox messages (marks them as completed) + parts := strings.Split(strings.TrimPrefix(f.path, "/"), "/") + if len(parts) == 3 && parts[1] == "inbox" && strings.HasSuffix(parts[2], ".json") { + sessID := parts[0] + msgID := strings.TrimSuffix(parts[2], ".json") + + // Validate ownership: only allow removing from own inbox or user inbox + if sessionID == "" || (sessID != "user" && sessID != sessionID) { + return errFcall(fc, "permission denied: can only remove from own inbox") + } + + mailMgr := s.mgr.GetMailManager() + if mailMgr == nil { + return errFcall(fc, "mailbox not available") + } + + if err := mailMgr.CompleteMessage(sessID, msgID); err != nil { + return errFcall(fc, err.Error()) + } + + cs.mu.Lock() + delete(cs.fids, fc.Fid) + cs.mu.Unlock() + + return &plan9.Fcall{Type: plan9.Rremove, Tag: fc.Tag} + } + + return errFcall(fc, "remove not supported for this file") +} + func (s *Server) readDir(path string, offset uint64, count uint32) []byte { var dirs []plan9.Dir @@ -703,6 +773,10 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte { Qid: plan9.Qid{Type: QTFile, Path: qidList}, Mode: 0444, Name: "list", Uid: "q", Gid: "q", Muid: "q", }) + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{Type: QTFile, Path: qidStatus}, Mode: 0444, Name: "status", + Uid: "q", Gid: "q", Muid: "q", + }) dirs = append(dirs, plan9.Dir{ Qid: plan9.Qid{Type: QTDir, Path: qidUser}, Mode: plan9.DMDIR | 0555, Name: "user", Uid: "q", Gid: "q", Muid: "q", @@ -891,6 +965,35 @@ func (s *Server) readFile(path string) string { return strings.Join(lines, "\n") + "\n" } + if path == "/status" { + var lines []string + mailMgr := s.mgr.GetMailManager() + for _, id := range s.mgr.List() { + sess := s.mgr.Get(id) + if sess != nil { + state := sess.State() + idleSince := "-" + inboxCount := 0 + + if tmuxSess, ok := sess.(*tmux.Session); ok { + if state == "idle" { + idleDuration := tmuxSess.IdleDuration() + if idleDuration > 0 { + idleSince = fmt.Sprintf("%ds", int(idleDuration.Seconds())) + } + } + } + + if mailMgr != nil { + inboxCount = len(mailMgr.GetInbox(id)) + } + + lines = append(lines, fmt.Sprintf("%s %s %s %d", id, state, idleSince, inboxCount)) + } + } + return strings.Join(lines, "\n") + "\n" + } + parts := strings.Split(strings.TrimPrefix(path, "/"), "/") // User message file: /user/mailbox/msg-*.json diff --git a/internal/session/manager.go b/internal/session/manager.go index 54e12ac..0f76431 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -158,51 +158,31 @@ func (m *Manager) processMailboxes() { } } - // 3. Process inbound messages for idle sessions (one message at a time) + // 3. Prompt idle agents with pending messages (after 30 seconds of idle) for _, sess := range sessions { if sess.State() != "idle" { continue } - messages, err := m.mailManager.GetPendingMessages(sess.ID()) - if err != nil || len(messages) == 0 { + // Check if session has been idle for more than 30 seconds + tmuxSess, ok := sess.(*tmux.Session) + if !ok { continue } - // Process first message only - msg := messages[0] - - // Format message for bot - prompt := fmt.Sprintf("[Message from %s]\nType: %s\nSubject: %s\n\n%s", - msg.From, msg.Type, msg.Subject, msg.Body) - - // Send asynchronously - only move to completed after success - _, err = sess.Send(context.Background(), prompt) - if err != nil { - msg.Retries++ - if msg.Retries > 3 { - // Discard after 3 failed attempts - m.mailManager.CompleteMessage(sess.ID(), msg.ID) - fmt.Fprintf(os.Stderr, "Message %s from %s to %s failed after 3 retries, discarding\nSubject: %s\nBody: %s\n", - msg.ID, msg.From, sess.ID(), msg.Subject, msg.Body) - - // Notify sender - if msg.From != "" && msg.From != "user" { - if senderSess := m.Get(msg.From); senderSess != nil { - if tmuxSess, ok := senderSess.(*tmux.Session); ok { - notification := fmt.Sprintf("\n[DELIVERY FAILURE]\nFailed to deliver message to %s after 3 attempts.\nSubject: %s\nBody: %s\n\n", - sess.ID(), msg.Subject, msg.Body) - tmuxSess.AppendToChatLog("SYSTEM", notification) - } - } - } - } - // Message stays in inbox for retry on next tick + idleDuration := tmuxSess.IdleDuration() + if idleDuration < 30*time.Second { continue } - // Success - move to completed - m.mailManager.CompleteMessage(sess.ID(), msg.ID) + // Check if inbox has messages + if !m.mailManager.HasPendingMessages(sess.ID()) { + continue + } + + // Prompt agent to check inbox + ctx := context.Background() + sess.Send(ctx, "check your inbox with 9p-read-inbox") } } diff --git a/kiro-cli/agent/anvillm-agent.json b/kiro-cli/agent/anvillm-agent.json index ca7e500..8c9952e 100644 --- a/kiro-cli/agent/anvillm-agent.json +++ b/kiro-cli/agent/anvillm-agent.json @@ -16,7 +16,7 @@ ], "stop": [ { - "command": "KIRO_PID=$(ps -o ppid= -p $PPID | tr -d ' '); cat $(namespace)/anvillm-session-id-$KIRO_PID 2>/dev/null | xargs -I{} sh -c 'echo idle | 9p write agent/{}/state 2>/dev/null' || true" + "command": "KIRO_PID=$(ps -o ppid= -p $PPID | tr -d ' '); cat $(namespace)/anvillm-session-id-$KIRO_PID 2>/dev/null | xargs -I{} sh -c 'echo idle | 9p write agent/{}/state 2>/dev/null; AGENT_ID=$(9p ls agent/ 2>/dev/null | head -1); [ -n \"$AGENT_ID\" ] && [ -n \"$(9p ls agent/$AGENT_ID/inbox/ 2>/dev/null)\" ] && echo \"[INBOX] You have pending messages. Check with: 9p-read-inbox\"' || true" } ] } diff --git a/mkfile b/mkfile index b03b17b..390aa7f 100644 --- a/mkfile +++ b/mkfile @@ -11,6 +11,8 @@ build:V: chmod 0755 $INSTALL_PATH/Alog cp scripts/anvillm-skills $INSTALL_PATH/anvillm-skills chmod 0755 $INSTALL_PATH/anvillm-skills + cp scripts/9p-read-inbox $INSTALL_PATH/9p-read-inbox + chmod 0755 $INSTALL_PATH/9p-read-inbox mkdir -p $INSTALL_PATH/Workflows cp workflows/* $INSTALL_PATH/Workflows chmod 0755 $INSTALL_PATH/Workflows/* diff --git a/scripts/9p-read-inbox b/scripts/9p-read-inbox new file mode 100755 index 0000000..e3c68e6 --- /dev/null +++ b/scripts/9p-read-inbox @@ -0,0 +1,50 @@ +#!/bin/bash +# Read next message from bot's inbox and mark it complete +# Usage: 9p-read-inbox [--help] + +set -e + +if [ "$1" = "--help" ]; then + echo "Usage: 9p-read-inbox" + echo "Read the next message from the bot's inbox and mark it complete" + exit 0 +fi + +# Get agent ID from namespace file +KIRO_PID=$(ps -o ppid= -p $PPID | tr -d ' ') +AGENT_ID=$(cat $(namespace)/anvillm-session-id-$KIRO_PID 2>/dev/null) + +if [ -z "$AGENT_ID" ]; then + echo "Error: Cannot determine agent ID" >&2 + exit 1 +fi + +INBOX_PATH="agent/$AGENT_ID/inbox" + +# List messages +MESSAGES=$(9p ls "$INBOX_PATH" 2>/dev/null) || { + echo "Error: Failed to list inbox" >&2 + exit 1 +} + +if [ -z "$MESSAGES" ]; then + echo "No messages" + exit 0 +fi + +# Read first message +FIRST_MSG=$(echo "$MESSAGES" | head -1) +MSG_JSON=$(9p read "$INBOX_PATH/$FIRST_MSG") || { + echo "Error: Failed to read message" >&2 + exit 1 +} + +# Display the message body +echo "$MSG_JSON" | jq -r '"[Message from \(.from)]", "Type: \(.type)", "Subject: \(.subject)", "", .body' + +# Mark as complete by writing to ctl file (strip .json extension) +MSG_ID="${FIRST_MSG%.json}" +echo "complete $MSG_ID" | 9p write "agent/$AGENT_ID/ctl" || { + echo "Warning: Failed to mark message as complete" >&2 + exit 1 +} diff --git a/skills/agent-kb/SKILL.md b/skills/agent-kb/SKILL.md index b0375dc..119f91e 100644 --- a/skills/agent-kb/SKILL.md +++ b/skills/agent-kb/SKILL.md @@ -9,8 +9,20 @@ description: Query and maintain a local knowledge base of code insights and arch Leverage accumulated knowledge in `~/doc/agent-kb` to avoid redundant code research. Add new entries when discoveries warrant preservation. +## Answer Priority Hierarchy + +When answering user questions, follow this strict priority order: + +1. **KB First** - Search `~/doc/agent-kb/` for relevant entries +2. **Code Search** - If KB doesn't have the answer, search/inspect the codebase +3. **Web Search** - If code doesn't provide clarity, research online with proper citations +4. **Training Data** - Only as last resort to fill remaining gaps + +**Always cite your source** - Tell the user which level you're answering from. + ## When to Use +- **When user asks any question** - Check KB first before answering - Encountering unfamiliar code patterns, services, or architecture - When user asks questions outside training data that would require code research - Before extensive codebase research—check KB first @@ -47,7 +59,12 @@ After significant code exploration, consider what was learned: grep -l "pattern" ~/doc/agent-kb/*.md ``` -3. Read relevant entries before researching code. +3. Search by content before answering questions: + ```bash + grep -il "keyword" ~/doc/agent-kb/*.md + ``` + +4. Read relevant entries before researching code. ## File Naming Convention @@ -79,7 +96,7 @@ Examples: tags: [tag1, tag2] identifier: YYYYMMDDThhmmss verified: YYYY-MM-DD - source: code-inspection | documentation | verbal | inferred + source: code-inspection | documentation | online | verbal | inferred --- # Title @@ -94,6 +111,7 @@ Examples: - `documentation` - from internal docs (Confluence, etc.) - `online` - from external online documentation - `verbal` - expert opinion, discussion, tacit knowledge + - `inferred` - derived from training data (lowest priority) If a source has a URL (online or documentation), add a `## Sources` section at the bottom of the document with the citation(s). @@ -112,4 +130,4 @@ When knowledge becomes stale or incomplete: ## What Belongs in the KB -- **Only verifiable knowledge from existing code** - no design alternatives, proposals, or "we could do X" speculation. Code is the source of truth. +- **Only verifiable knowledge from authoritative sources** - code inspection, official documentation, or properly cited research. No speculation, design alternatives, or "we could do X" proposals.