Merge branch 'feat/hyperguild-phase1'
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"supervisor": {
|
"supervisor": {
|
||||||
"url": "http://localhost:3200/mcp",
|
"url": "http://localhost:3200/mcp",
|
||||||
"description": "Skill workers — TDD (red/green/refactor), more coming"
|
"description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,12 @@ SUPERVISOR_MODELS_FILE=./config/models.yaml
|
|||||||
# LiteLLM gateway (iguana)
|
# LiteLLM gateway (iguana)
|
||||||
LITELLM_BASE_URL=http://iguana:4000
|
LITELLM_BASE_URL=http://iguana:4000
|
||||||
LITELLM_API_KEY=your-litellm-master-key
|
LITELLM_API_KEY=your-litellm-master-key
|
||||||
|
|
||||||
|
# Ingestion server
|
||||||
|
INGEST_BASE_URL=http://localhost:3300
|
||||||
|
INGEST_PORT=3300
|
||||||
|
INGEST_BRAIN_DIR=./brain
|
||||||
|
|
||||||
|
# Brain directories
|
||||||
|
SUPERVISOR_SESSIONS_DIR=./brain/sessions
|
||||||
|
SUPERVISOR_BRAIN_DIR=./brain
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,6 +6,10 @@ bin/
|
|||||||
*.exe
|
*.exe
|
||||||
cmd/supervisor/supervisor
|
cmd/supervisor/supervisor
|
||||||
|
|
||||||
|
# Brain content — keep wiki and structure, exclude session logs and training data
|
||||||
|
brain/sessions/*.jsonl
|
||||||
|
brain/training-data/**/*.jsonl
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
|
|||||||
21
Taskfile.yml
21
Taskfile.yml
@@ -73,3 +73,24 @@ tasks:
|
|||||||
curl -s -X POST http://localhost:${SUPERVISOR_PORT:-3200}/mcp \
|
curl -s -X POST http://localhost:${SUPERVISOR_PORT:-3200}/mcp \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
|
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
|
||||||
|
|
||||||
|
ingestion:build:
|
||||||
|
desc: Build ingestion server binary
|
||||||
|
cmds:
|
||||||
|
- go build -o bin/ingestion-server ./cmd/server
|
||||||
|
dir: ingestion
|
||||||
|
|
||||||
|
ingestion:dev:
|
||||||
|
desc: Run ingestion server in development mode
|
||||||
|
env:
|
||||||
|
INGEST_BRAIN_DIR: "{{.ROOT_DIR}}/brain"
|
||||||
|
INGEST_PORT: "3300"
|
||||||
|
cmds:
|
||||||
|
- go run ./cmd/server
|
||||||
|
dir: ingestion
|
||||||
|
|
||||||
|
ingestion:test:
|
||||||
|
desc: Run ingestion tests
|
||||||
|
cmds:
|
||||||
|
- go test ./... -v
|
||||||
|
dir: ingestion
|
||||||
|
|||||||
0
brain/raw/.gitkeep
Normal file
0
brain/raw/.gitkeep
Normal file
3
brain/raw/tdd-pattern-test.md
Normal file
3
brain/raw/tdd-pattern-test.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# TDD Pattern
|
||||||
|
|
||||||
|
Always write the failing test first.
|
||||||
0
brain/sessions/.gitkeep
Normal file
0
brain/sessions/.gitkeep
Normal file
0
brain/training-data/dpo/.gitkeep
Normal file
0
brain/training-data/dpo/.gitkeep
Normal file
0
brain/training-data/rl/.gitkeep
Normal file
0
brain/training-data/rl/.gitkeep
Normal file
0
brain/training-data/sft/.gitkeep
Normal file
0
brain/training-data/sft/.gitkeep
Normal file
0
brain/wiki/concepts/.gitkeep
Normal file
0
brain/wiki/concepts/.gitkeep
Normal file
3
brain/wiki/concepts/tdd-pattern-test.md
Normal file
3
brain/wiki/concepts/tdd-pattern-test.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# TDD Pattern
|
||||||
|
|
||||||
|
Always write the failing test first.
|
||||||
0
brain/wiki/entities/.gitkeep
Normal file
0
brain/wiki/entities/.gitkeep
Normal file
0
brain/wiki/sources/.gitkeep
Normal file
0
brain/wiki/sources/.gitkeep
Normal file
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,7 +10,12 @@ import (
|
|||||||
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
||||||
"github.com/mathiasbq/supervisor/internal/mcp"
|
"github.com/mathiasbq/supervisor/internal/mcp"
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
"github.com/mathiasbq/supervisor/internal/registry"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/brain"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/org"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/sessionlog"
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/tdd"
|
"github.com/mathiasbq/supervisor/internal/skills/tdd"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/tier"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -39,12 +45,22 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retroPrompt, err := os.ReadFile(cfg.ConfigDir + "/retrospective.md")
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("read retrospective.md", "path", cfg.ConfigDir+"/retrospective.md", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
executor := iexec.New(iexec.Config{
|
executor := iexec.New(iexec.Config{
|
||||||
SystemPrompt: string(systemPrompt),
|
SystemPrompt: string(systemPrompt),
|
||||||
LiteLLMBaseURL: cfg.LiteLLMBaseURL,
|
LiteLLMBaseURL: cfg.LiteLLMBaseURL,
|
||||||
LiteLLMAPIKey: cfg.LiteLLMAPIKey,
|
LiteLLMAPIKey: cfg.LiteLLMAPIKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
tierFn := func(ctx context.Context) tier.Info {
|
||||||
|
return tier.Detect(ctx, "https://api.anthropic.com", cfg.LiteLLMBaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
reg.Register(tdd.New(tdd.Config{
|
reg.Register(tdd.New(tdd.Config{
|
||||||
SystemPrompt: string(systemPrompt),
|
SystemPrompt: string(systemPrompt),
|
||||||
@@ -52,6 +68,21 @@ func main() {
|
|||||||
DefaultModel: models.Resolve("tdd", ""),
|
DefaultModel: models.Resolve("tdd", ""),
|
||||||
ExecutorFn: executor.Run,
|
ExecutorFn: executor.Run,
|
||||||
}))
|
}))
|
||||||
|
reg.Register(brain.New(brain.Config{
|
||||||
|
IngestBaseURL: cfg.IngestBaseURL,
|
||||||
|
}))
|
||||||
|
reg.Register(org.New(org.Config{
|
||||||
|
TierFn: tierFn,
|
||||||
|
}))
|
||||||
|
reg.Register(sessionlog.New(sessionlog.Config{
|
||||||
|
SessionsDir: cfg.SessionsDir,
|
||||||
|
}))
|
||||||
|
reg.Register(retrospective.New(retrospective.Config{
|
||||||
|
SkillPrompt: string(retroPrompt),
|
||||||
|
DefaultModel: models.Resolve("retrospective", ""),
|
||||||
|
SessionsDir: cfg.SessionsDir,
|
||||||
|
ExecutorFn: executor.Run,
|
||||||
|
}))
|
||||||
|
|
||||||
srv := mcp.NewServer(reg)
|
srv := mcp.NewServer(reg)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
default: ollama/qwen3-coder-30b-tuned
|
default: ollama/qwen3-coder-30b-tuned
|
||||||
|
|
||||||
skills:
|
skills:
|
||||||
tdd: ollama/qwen3-coder-30b-tuned
|
tdd: ollama/qwen3-coder-30b-tuned
|
||||||
review: ollama/devstral-tuned
|
review: ollama/devstral-tuned
|
||||||
debug: ollama/deepseek-r1-tuned
|
debug: ollama/deepseek-r1-tuned
|
||||||
|
retrospective: ollama/qwen3-coder-30b-tuned
|
||||||
|
|||||||
27
config/supervisor/protocols.md
Normal file
27
config/supervisor/protocols.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# The Hyperguild Way
|
||||||
|
|
||||||
|
These protocols are injected into every worker invocation. They define how you behave as a member of the hyperguild.
|
||||||
|
|
||||||
|
## Output contract
|
||||||
|
|
||||||
|
Every response is raw JSON matching the response schema. No preamble, no prose, no markdown. Malformed output is treated as a failed invocation.
|
||||||
|
|
||||||
|
## Quality gate
|
||||||
|
|
||||||
|
`verified: true` only when a subprocess exit code confirms the outcome. Never self-assess. "I think the tests pass" is not verified.
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
If stuck after 3 attempts, return `status: error` with a clear `message` explaining why. Do not retry silently. Do not fabricate a passing result.
|
||||||
|
|
||||||
|
## Working offline
|
||||||
|
|
||||||
|
If brain context is absent from your prompt, proceed using your discipline file only. Note the gap in your `message` field: "no brain context available".
|
||||||
|
|
||||||
|
## Handoff format
|
||||||
|
|
||||||
|
Structure your output so the next worker in a chain can consume it without transformation. Use the standard result schema. Do not add extra fields.
|
||||||
|
|
||||||
|
## Session logging
|
||||||
|
|
||||||
|
The Go skill handler records your invocation in the session log automatically. You do not need to do this yourself.
|
||||||
40
config/supervisor/retrospective.md
Normal file
40
config/supervisor/retrospective.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Retrospective Worker Discipline
|
||||||
|
|
||||||
|
You are the retrospective worker. Your job is to review a completed coding session and identify knowledge worth preserving in the hyperguild brain.
|
||||||
|
|
||||||
|
## What you receive
|
||||||
|
|
||||||
|
- A session log in JSON format listing every skill invocation: what was attempted, what failed, what passed, how long it took.
|
||||||
|
|
||||||
|
## What you produce
|
||||||
|
|
||||||
|
For each significant learning, call brain_write with a structured markdown note. Then return a JSON result summarising what you wrote.
|
||||||
|
|
||||||
|
## What is worth preserving
|
||||||
|
|
||||||
|
- Patterns that worked and should be repeated
|
||||||
|
- Failures that revealed something non-obvious about the codebase or the discipline
|
||||||
|
- Decisions made during the session (architectural, structural, tooling)
|
||||||
|
- Anything that contradicts or extends what the brain already knows
|
||||||
|
|
||||||
|
## What is NOT worth preserving
|
||||||
|
|
||||||
|
- Routine TDD cycles with no surprises
|
||||||
|
- Single-attempt passes with no interesting context
|
||||||
|
- Mechanical operations (file moves, renames, formatting)
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
Return JSON matching the standard result schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "pass",
|
||||||
|
"phase": "retrospective",
|
||||||
|
"skill": "retrospective",
|
||||||
|
"verified": true,
|
||||||
|
"message": "wrote N entries to brain/raw/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`verified` is true when you successfully called brain_write at least once and received a confirmation. If the session had nothing worth writing, return `verified: true` with `message: "no novel learnings in this session"`.
|
||||||
37
ingestion/cmd/server/main.go
Normal file
37
ingestion/cmd/server/main.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// ingestion/cmd/server/main.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
|
brainDir := os.Getenv("INGEST_BRAIN_DIR")
|
||||||
|
if brainDir == "" {
|
||||||
|
brainDir = "../brain"
|
||||||
|
}
|
||||||
|
|
||||||
|
port := os.Getenv("INGEST_PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "3300"
|
||||||
|
}
|
||||||
|
|
||||||
|
h := api.NewHandler(brainDir, logger)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/query", h.Query)
|
||||||
|
mux.HandleFunc("/write", h.Write)
|
||||||
|
|
||||||
|
addr := ":" + port
|
||||||
|
logger.Info("ingestion server starting", "addr", addr, "brain_dir", brainDir)
|
||||||
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
|
logger.Error("server stopped", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
ingestion/go.mod
Normal file
11
ingestion/go.mod
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module github.com/mathiasbq/hyperguild/ingestion
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
|
|
||||||
|
require github.com/stretchr/testify v1.11.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
9
ingestion/go.sum
Normal file
9
ingestion/go.sum
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
116
ingestion/internal/api/handler.go
Normal file
116
ingestion/internal/api/handler.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// ingestion/internal/api/handler.go
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler serves the ingestion HTTP API.
|
||||||
|
type Handler struct {
|
||||||
|
brainDir string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler constructs a Handler. brainDir is the absolute path to brain/.
|
||||||
|
func NewHandler(brainDir string, logger *slog.Logger) *Handler {
|
||||||
|
return &Handler{brainDir: brainDir, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryRequest struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeRequest struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query handles POST /query — full-text search across the brain wiki.
|
||||||
|
func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req queryRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Query) == "" {
|
||||||
|
http.Error(w, "query is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Limit == 0 {
|
||||||
|
req.Limit = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := search.Query(h.brainDir, req.Query, req.Limit)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("query failed", "err", err)
|
||||||
|
http.Error(w, "search error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{"results": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write handles POST /write — write raw content to brain/raw/.
|
||||||
|
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req writeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Content == "" {
|
||||||
|
http.Error(w, "content is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := req.Filename
|
||||||
|
if filename == "" {
|
||||||
|
filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405"))
|
||||||
|
}
|
||||||
|
|
||||||
|
rawDir := filepath.Join(h.brainDir, "raw")
|
||||||
|
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
||||||
|
http.Error(w, "failed to create raw dir", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
finalContent := req.Content
|
||||||
|
if req.Type != "" || req.Domain != "" {
|
||||||
|
var fm strings.Builder
|
||||||
|
fm.WriteString("---\n")
|
||||||
|
if req.Type != "" {
|
||||||
|
fmt.Fprintf(&fm, "type: %s\n", req.Type)
|
||||||
|
}
|
||||||
|
if req.Domain != "" {
|
||||||
|
fmt.Fprintf(&fm, "domain: %s\n", req.Domain)
|
||||||
|
}
|
||||||
|
fm.WriteString("---\n")
|
||||||
|
finalContent = fm.String() + req.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := filepath.Join(rawDir, filepath.Base(filename))
|
||||||
|
if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil {
|
||||||
|
h.logger.Error("write failed", "err", err)
|
||||||
|
http.Error(w, "write error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, _ := filepath.Rel(h.brainDir, dest)
|
||||||
|
writeJSON(w, map[string]string{"path": filepath.ToSlash(rel)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||||
|
}
|
||||||
115
ingestion/internal/api/handler_test.go
Normal file
115
ingestion/internal/api/handler_test.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// ingestion/internal/api/handler_test.go
|
||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setup(t *testing.T) (string, *api.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, "wiki", "concepts", "tdd.md"),
|
||||||
|
[]byte("---\ntitle: TDD\ndomain: software\n---\n\nTest-driven development is a discipline.\n"),
|
||||||
|
0o644,
|
||||||
|
))
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
return dir, api.NewHandler(dir, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuery_ReturnsResults(t *testing.T) {
|
||||||
|
_, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{"query": "test driven", "limit": 5})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.Query(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
results := resp["results"].([]any)
|
||||||
|
assert.NotEmpty(t, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrite_CreatesRawFile(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"content": "# Test note\n\nSome content.",
|
||||||
|
"filename": "test-note.md",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.Write(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
var resp map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
assert.NotEmpty(t, resp["path"])
|
||||||
|
|
||||||
|
written := filepath.Join(dir, "raw", "test-note.md")
|
||||||
|
content, err := os.ReadFile(written)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(content), "Some content.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuery_RequiresQuery(t *testing.T) {
|
||||||
|
_, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{"limit": 5})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.Query(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"content": "Some learning.",
|
||||||
|
"filename": "typed-note.md",
|
||||||
|
"type": "concept",
|
||||||
|
"domain": "software",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.Write(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
content, err := os.ReadFile(filepath.Join(dir, "raw", "typed-note.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(content), "type: concept")
|
||||||
|
assert.Contains(t, string(content), "domain: software")
|
||||||
|
assert.Contains(t, string(content), "Some learning.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.Write(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
entries, _ := os.ReadDir(filepath.Join(dir, "raw"))
|
||||||
|
assert.Len(t, entries, 1)
|
||||||
|
assert.True(t, strings.HasSuffix(entries[0].Name(), ".md"))
|
||||||
|
}
|
||||||
120
ingestion/internal/search/search.go
Normal file
120
ingestion/internal/search/search.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// ingestion/internal/search/search.go
|
||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result is a single search hit from the brain wiki.
|
||||||
|
type Result struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Excerpt string `json:"excerpt"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query searches all .md files under brainDir/wiki/ for pages containing
|
||||||
|
// any of the whitespace-separated terms in query. Returns up to limit results
|
||||||
|
// sorted by score descending.
|
||||||
|
func Query(brainDir, query string, limit int) ([]Result, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5
|
||||||
|
}
|
||||||
|
terms := strings.Fields(strings.ToLower(query))
|
||||||
|
if len(terms) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []Result
|
||||||
|
|
||||||
|
err := filepath.WalkDir(filepath.Join(brainDir, "wiki"), func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("search: skipping path", "path", path, "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("search: skipping unreadable file", "path", path, "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(string(content))
|
||||||
|
score := 0
|
||||||
|
for _, term := range terms {
|
||||||
|
score += strings.Count(lower, term)
|
||||||
|
}
|
||||||
|
if score == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(brainDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rel path: %w", err)
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
|
||||||
|
results = append(results, Result{
|
||||||
|
Path: rel,
|
||||||
|
Title: extractTitle(string(content), d.Name()),
|
||||||
|
Excerpt: excerpt(string(content), 300),
|
||||||
|
Score: score,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].Score > results[j].Score
|
||||||
|
})
|
||||||
|
if len(results) > limit {
|
||||||
|
results = results[:limit]
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTitle(content, filename string) string {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||||
|
inFrontmatter := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
if !inFrontmatter {
|
||||||
|
inFrontmatter = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if inFrontmatter {
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if ok && strings.TrimSpace(key) == "title" {
|
||||||
|
return strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(filename, ".md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func excerpt(content string, maxLen int) string {
|
||||||
|
// Skip frontmatter, return first maxLen chars of body.
|
||||||
|
parts := strings.SplitN(content, "---", 3)
|
||||||
|
body := content
|
||||||
|
if len(parts) == 3 {
|
||||||
|
body = strings.TrimSpace(parts[2])
|
||||||
|
}
|
||||||
|
if len(body) > maxLen {
|
||||||
|
return body[:maxLen] + "…"
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
54
ingestion/internal/search/search_test.go
Normal file
54
ingestion/internal/search/search_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// ingestion/internal/search/search_test.go
|
||||||
|
package search_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearch_ReturnsMatchingPages(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755))
|
||||||
|
|
||||||
|
// Write a concept page mentioning "retry"
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, "wiki", "concepts", "retry-logic.md"),
|
||||||
|
[]byte("---\ntitle: Retry Logic\ndomain: software\n---\n\nRetry logic handles transient failures by re-attempting operations.\n"),
|
||||||
|
0o644,
|
||||||
|
))
|
||||||
|
// Write an unrelated page
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, "wiki", "concepts", "database.md"),
|
||||||
|
[]byte("---\ntitle: Database\ndomain: software\n---\n\nA database stores structured data.\n"),
|
||||||
|
0o644,
|
||||||
|
))
|
||||||
|
|
||||||
|
results, err := search.Query(dir, "retry transient", 5)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, results, 1)
|
||||||
|
assert.Equal(t, "wiki/concepts/retry-logic.md", results[0].Path)
|
||||||
|
assert.Equal(t, "Retry Logic", results[0].Title)
|
||||||
|
assert.Greater(t, results[0].Score, 0)
|
||||||
|
assert.Contains(t, results[0].Excerpt, "Retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_RespectsLimit(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755))
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, "wiki", "concepts", fmt.Sprintf("page-%d.md", i)),
|
||||||
|
[]byte(fmt.Sprintf("---\ntitle: Page %d\n---\n\nThis page mentions retry.\n", i)),
|
||||||
|
0o644,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
results, err := search.Query(dir, "retry", 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.LessOrEqual(t, len(results), 3)
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ type Config struct {
|
|||||||
LiteLLMAPIKey string // LITELLM_API_KEY
|
LiteLLMAPIKey string // LITELLM_API_KEY
|
||||||
ConfigDir string // SUPERVISOR_CONFIG_DIR, default ./config/supervisor
|
ConfigDir string // SUPERVISOR_CONFIG_DIR, default ./config/supervisor
|
||||||
ModelsFile string // SUPERVISOR_MODELS_FILE, default <ConfigDir>/../models.yaml
|
ModelsFile string // SUPERVISOR_MODELS_FILE, default <ConfigDir>/../models.yaml
|
||||||
|
IngestBaseURL string // INGEST_BASE_URL, default http://localhost:3300
|
||||||
|
SessionsDir string // SUPERVISOR_SESSIONS_DIR, default ./brain/sessions
|
||||||
|
BrainDir string // SUPERVISOR_BRAIN_DIR, default ./brain
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -18,6 +21,9 @@ func Load() (Config, error) {
|
|||||||
ConfigDir: envOr("SUPERVISOR_CONFIG_DIR", "./config/supervisor"),
|
ConfigDir: envOr("SUPERVISOR_CONFIG_DIR", "./config/supervisor"),
|
||||||
}
|
}
|
||||||
cfg.ModelsFile = envOr("SUPERVISOR_MODELS_FILE", cfg.ConfigDir+"/../models.yaml")
|
cfg.ModelsFile = envOr("SUPERVISOR_MODELS_FILE", cfg.ConfigDir+"/../models.yaml")
|
||||||
|
cfg.IngestBaseURL = envOr("INGEST_BASE_URL", "http://localhost:3300")
|
||||||
|
cfg.SessionsDir = envOr("SUPERVISOR_SESSIONS_DIR", "./brain/sessions")
|
||||||
|
cfg.BrainDir = envOr("SUPERVISOR_BRAIN_DIR", "./brain")
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,18 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
t.Setenv("LITELLM_BASE_URL", "")
|
t.Setenv("LITELLM_BASE_URL", "")
|
||||||
t.Setenv("LITELLM_API_KEY", "")
|
t.Setenv("LITELLM_API_KEY", "")
|
||||||
t.Setenv("SUPERVISOR_CONFIG_DIR", "")
|
t.Setenv("SUPERVISOR_CONFIG_DIR", "")
|
||||||
|
t.Setenv("INGEST_BASE_URL", "")
|
||||||
|
t.Setenv("SUPERVISOR_SESSIONS_DIR", "")
|
||||||
|
t.Setenv("SUPERVISOR_BRAIN_DIR", "")
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "3200", cfg.Port)
|
assert.Equal(t, "3200", cfg.Port)
|
||||||
assert.Equal(t, "http://iguana:4000", cfg.LiteLLMBaseURL)
|
assert.Equal(t, "http://iguana:4000", cfg.LiteLLMBaseURL)
|
||||||
assert.Equal(t, "./config/supervisor", cfg.ConfigDir)
|
assert.Equal(t, "./config/supervisor", cfg.ConfigDir)
|
||||||
|
assert.Equal(t, "http://localhost:3300", cfg.IngestBaseURL)
|
||||||
|
assert.Equal(t, "./brain/sessions", cfg.SessionsDir)
|
||||||
|
assert.Equal(t, "./brain", cfg.BrainDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadFromEnv(t *testing.T) {
|
func TestLoadFromEnv(t *testing.T) {
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ type Result struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var validStatuses = map[string]bool{"pass": true, "fail": true, "error": true}
|
var validStatuses = map[string]bool{"pass": true, "fail": true, "error": true}
|
||||||
var validPhases = map[string]bool{"red": true, "green": true, "refactor": true}
|
var validPhases = map[string]bool{
|
||||||
|
"red": true,
|
||||||
|
"green": true,
|
||||||
|
"refactor": true,
|
||||||
|
"retrospective": true,
|
||||||
|
}
|
||||||
|
|
||||||
func (r Result) Validate() error {
|
func (r Result) Validate() error {
|
||||||
var errs []string
|
var errs []string
|
||||||
|
|||||||
93
internal/session/session.go
Normal file
93
internal/session/session.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// internal/session/session.go
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is one skill invocation record, appended to the session JSONL log.
|
||||||
|
type Entry struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Skill string `json:"skill"`
|
||||||
|
Phase string `json:"phase,omitempty"`
|
||||||
|
ProjectRoot string `json:"project_root,omitempty"`
|
||||||
|
Input json.RawMessage `json:"input,omitempty"`
|
||||||
|
Attempts []Attempt `json:"attempts,omitempty"`
|
||||||
|
FinalStatus string `json:"final_status"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
ModelUsed string `json:"model_used,omitempty"`
|
||||||
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt represents one subprocess invocation within a skill call.
|
||||||
|
type Attempt struct {
|
||||||
|
Attempt int `json:"attempt"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
OutputSummary string `json:"output_summary,omitempty"`
|
||||||
|
RunnerOutput string `json:"runner_output,omitempty"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append writes entry as a single JSON line to sessionsDir/{sessionID}.jsonl.
|
||||||
|
func Append(sessionsDir, sessionID string, entry Entry) error {
|
||||||
|
if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create sessions dir: %w", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(sessionsDir, sessionID+".jsonl")
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open session log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return fmt.Errorf("marshal entry: %w", err)
|
||||||
|
}
|
||||||
|
if _, err = fmt.Fprintf(f, "%s\n", line); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return fmt.Errorf("write entry: %w", err)
|
||||||
|
}
|
||||||
|
if err = f.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close session log: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read returns all entries for sessionID. Returns empty slice if no log exists.
|
||||||
|
func Read(sessionsDir, sessionID string) ([]Entry, error) {
|
||||||
|
path := filepath.Join(sessionsDir, sessionID+".jsonl")
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return []Entry{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open session log: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var entries []Entry
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 0, 256*1024), 1<<20) // up to 1 MB per line
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var e Entry
|
||||||
|
if err := json.Unmarshal(line, &e); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse entry: %w", err)
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, scanner.Err()
|
||||||
|
}
|
||||||
63
internal/session/session_test.go
Normal file
63
internal/session/session_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// internal/session/session_test.go
|
||||||
|
package session_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/session"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppend_WritesJSONLEntry(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
entry := session.Entry{
|
||||||
|
SessionID: "test-session-1",
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Skill: "tdd_green",
|
||||||
|
Phase: "green",
|
||||||
|
ProjectRoot: "/tmp/myproject",
|
||||||
|
FinalStatus: "pass",
|
||||||
|
ModelUsed: "ollama/qwen3",
|
||||||
|
DurationMs: 5000,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, session.Append(dir, "test-session-1", entry))
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "test-session-1.jsonl")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var got session.Entry
|
||||||
|
require.NoError(t, json.Unmarshal(data, &got))
|
||||||
|
assert.Equal(t, "test-session-1", got.SessionID)
|
||||||
|
assert.Equal(t, "tdd_green", got.Skill)
|
||||||
|
assert.Equal(t, "pass", got.FinalStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppend_AppendsMultipleEntries(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
require.NoError(t, session.Append(dir, "s1", session.Entry{
|
||||||
|
SessionID: "s1",
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Skill: "tdd_red",
|
||||||
|
FinalStatus: "pass",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := session.Read(dir, "s1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, entries, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRead_EmptyWhenNoFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
entries, err := session.Read(dir, "missing")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries)
|
||||||
|
}
|
||||||
87
internal/skills/brain/handlers.go
Normal file
87
internal/skills/brain/handlers.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// internal/skills/brain/handlers.go
|
||||||
|
package brain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle dispatches brain_query and brain_write tool calls.
|
||||||
|
func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
switch tool {
|
||||||
|
case "brain_query":
|
||||||
|
return s.query(ctx, args)
|
||||||
|
case "brain_write":
|
||||||
|
return s.write(ctx, args)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown brain tool: %s", tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryArgs struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Skill) query(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a queryArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
if a.Query == "" {
|
||||||
|
return nil, fmt.Errorf("query is required")
|
||||||
|
}
|
||||||
|
if a.Limit == 0 {
|
||||||
|
a.Limit = 5
|
||||||
|
}
|
||||||
|
return s.post(ctx, "/query", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeArgs struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Skill) write(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a writeArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
if a.Content == "" {
|
||||||
|
return nil, fmt.Errorf("content is required")
|
||||||
|
}
|
||||||
|
return s.post(ctx, "/write", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Skill) post(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.IngestBaseURL+path, bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("call ingestion server: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
out, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("ingestion server returned %d: %s", resp.StatusCode, out)
|
||||||
|
}
|
||||||
|
return json.RawMessage(out), nil
|
||||||
|
}
|
||||||
65
internal/skills/brain/handlers_test.go
Normal file
65
internal/skills/brain/handlers_test.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// internal/skills/brain/handlers_test.go
|
||||||
|
package brain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/brain"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandle_BrainQuery_CallsIngestServer(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/query", r.URL.Path)
|
||||||
|
called = true
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{
|
||||||
|
{"path": "wiki/concepts/tdd.md", "title": "TDD", "excerpt": "Test-driven development.", "score": 3},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
s := brain.New(brain.Config{IngestBaseURL: srv.URL})
|
||||||
|
args, _ := json.Marshal(map[string]string{"query": "test driven development"})
|
||||||
|
out, err := s.Handle(context.Background(), "brain_query", args)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, called)
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
|
results := result["results"].([]any)
|
||||||
|
assert.Len(t, results, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandle_BrainWrite_CallsIngestServer(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/write", r.URL.Path)
|
||||||
|
var body map[string]string
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
|
||||||
|
assert.Equal(t, "concept", body["type"])
|
||||||
|
assert.Equal(t, "# Test\n\nSome learning.", body["content"])
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"path": "raw/test.md"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
s := brain.New(brain.Config{IngestBaseURL: srv.URL})
|
||||||
|
args, _ := json.Marshal(map[string]string{"content": "# Test\n\nSome learning.", "type": "concept"})
|
||||||
|
out, err := s.Handle(context.Background(), "brain_write", args)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var result map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
|
assert.Equal(t, "raw/test.md", result["path"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandle_UnknownTool_ReturnsError(t *testing.T) {
|
||||||
|
s := brain.New(brain.Config{IngestBaseURL: "http://localhost:3300"})
|
||||||
|
_, err := s.Handle(context.Background(), "brain_unknown", nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
55
internal/skills/brain/skill.go
Normal file
55
internal/skills/brain/skill.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// internal/skills/brain/skill.go
|
||||||
|
package brain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds brain skill configuration.
|
||||||
|
type Config struct {
|
||||||
|
IngestBaseURL string // base URL of the ingestion HTTP server, e.g. http://localhost:3300
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill implements registry.Skill for brain_query and brain_write.
|
||||||
|
type Skill struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a brain Skill.
|
||||||
|
func New(cfg Config) *Skill { return &Skill{cfg: cfg} }
|
||||||
|
|
||||||
|
// Name returns the skill name used for routing.
|
||||||
|
func (s *Skill) Name() string { return "brain" }
|
||||||
|
|
||||||
|
// Tools returns the MCP tool definitions for brain_query and brain_write.
|
||||||
|
func (s *Skill) Tools() []registry.ToolDef {
|
||||||
|
schema := func(required []string, props map[string]any) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(map[string]any{"type": "object", "required": required, "properties": props})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
str := map[string]any{"type": "string"}
|
||||||
|
num := map[string]any{"type": "integer"}
|
||||||
|
|
||||||
|
return []registry.ToolDef{
|
||||||
|
{
|
||||||
|
Name: "brain_query",
|
||||||
|
Description: "Search the hyperguild brain wiki for relevant knowledge. Call this before starting any significant task.",
|
||||||
|
InputSchema: schema([]string{"query"}, map[string]any{
|
||||||
|
"query": str,
|
||||||
|
"limit": num,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "brain_write",
|
||||||
|
Description: "Write a raw knowledge note to the brain for later ingestion into the wiki.",
|
||||||
|
InputSchema: schema([]string{"content"}, map[string]any{
|
||||||
|
"content": str,
|
||||||
|
"type": str,
|
||||||
|
"domain": str,
|
||||||
|
"filename": str,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
21
internal/skills/org/handlers.go
Normal file
21
internal/skills/org/handlers.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// internal/skills/org/handlers.go
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle dispatches the tier tool call.
|
||||||
|
func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
if tool != "tier" {
|
||||||
|
return nil, fmt.Errorf("unknown org tool: %s", tool)
|
||||||
|
}
|
||||||
|
info := s.cfg.TierFn(ctx)
|
||||||
|
b, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal tier info: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
29
internal/skills/org/handlers_test.go
Normal file
29
internal/skills/org/handlers_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// internal/skills/org/handlers_test.go
|
||||||
|
package org_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/org"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/tier"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandle_Tier_ReturnsTierInfo(t *testing.T) {
|
||||||
|
s := org.New(org.Config{
|
||||||
|
TierFn: func(ctx context.Context) tier.Info {
|
||||||
|
return tier.Info{Tier: tier.LANOnly, Label: "lan-only", ManagedAgents: false}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
out, err := s.Handle(context.Background(), "tier", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var info tier.Info
|
||||||
|
require.NoError(t, json.Unmarshal(out, &info))
|
||||||
|
assert.Equal(t, tier.LANOnly, info.Tier)
|
||||||
|
assert.Equal(t, "lan-only", info.Label)
|
||||||
|
assert.False(t, info.ManagedAgents)
|
||||||
|
}
|
||||||
40
internal/skills/org/skill.go
Normal file
40
internal/skills/org/skill.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// internal/skills/org/skill.go
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/registry"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/tier"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TierFn returns the current tier. Injected for testability.
|
||||||
|
type TierFn func(ctx context.Context) tier.Info
|
||||||
|
|
||||||
|
// Config holds org skill configuration.
|
||||||
|
type Config struct {
|
||||||
|
TierFn TierFn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill implements registry.Skill for the tier tool.
|
||||||
|
type Skill struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs an org Skill.
|
||||||
|
func New(cfg Config) *Skill { return &Skill{cfg: cfg} }
|
||||||
|
|
||||||
|
// Name returns the skill name.
|
||||||
|
func (s *Skill) Name() string { return "org" }
|
||||||
|
|
||||||
|
// Tools returns the MCP tool definitions.
|
||||||
|
func (s *Skill) Tools() []registry.ToolDef {
|
||||||
|
return []registry.ToolDef{
|
||||||
|
{
|
||||||
|
Name: "tier",
|
||||||
|
Description: "Returns the current operating tier: 1=full-online (Claude+Ollama+Managed Agents), 2=lan-only (Ollama only), 3=airplane (minimal). Call at session start to know which models and capabilities are available.",
|
||||||
|
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
70
internal/skills/retrospective/handlers.go
Normal file
70
internal/skills/retrospective/handlers.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// internal/skills/retrospective/handlers.go
|
||||||
|
package retrospective
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
type retroArgs struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dispatches the retrospective tool call.
|
||||||
|
func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
if tool != "retrospective" {
|
||||||
|
return nil, fmt.Errorf("unknown retrospective tool: %s", tool)
|
||||||
|
}
|
||||||
|
var a retroArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
if a.SessionID == "" {
|
||||||
|
return nil, fmt.Errorf("session_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
model := a.Model
|
||||||
|
if model == "" {
|
||||||
|
model = s.cfg.DefaultModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read session log entries (empty slice if no log exists yet).
|
||||||
|
entries, err := session.Read(s.cfg.SessionsDir, a.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read session log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logJSON, err := json.MarshalIndent(entries, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal session log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
taskPrompt := fmt.Sprintf(
|
||||||
|
"SESSION_ID: %s\n\nSESSION_LOG:\n%s\n\nReview this session log. Identify what is novel or worth preserving as organizational knowledge. Write structured entries to brain/raw/ via brain_write. Return JSON result when done.",
|
||||||
|
a.SessionID, string(logJSON),
|
||||||
|
)
|
||||||
|
|
||||||
|
if s.cfg.ExecutorFn == nil {
|
||||||
|
return nil, fmt.Errorf("no executor configured")
|
||||||
|
}
|
||||||
|
result, err := s.cfg.ExecutorFn(ctx, iexec.Request{
|
||||||
|
SkillPrompt: s.cfg.SkillPrompt,
|
||||||
|
TaskPrompt: taskPrompt,
|
||||||
|
Model: model,
|
||||||
|
Tools: "Bash,Read,Write",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("retrospective worker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal result: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
49
internal/skills/retrospective/handlers_test.go
Normal file
49
internal/skills/retrospective/handlers_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// internal/skills/retrospective/handlers_test.go
|
||||||
|
package retrospective_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandle_Retrospective_RequiresSessionID(t *testing.T) {
|
||||||
|
s := retrospective.New(retrospective.Config{})
|
||||||
|
_, err := s.Handle(context.Background(), "retrospective", json.RawMessage(`{}`))
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "session_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandle_Retrospective_BuildsPromptWithSessionLog(t *testing.T) {
|
||||||
|
var capturedReq iexec.Request
|
||||||
|
s := retrospective.New(retrospective.Config{
|
||||||
|
SkillPrompt: "retrospective discipline",
|
||||||
|
DefaultModel: "ollama/test",
|
||||||
|
SessionsDir: t.TempDir(), // empty dir, no session file — that's OK, session.Read returns nil
|
||||||
|
ExecutorFn: func(_ context.Context, req iexec.Request) (iexec.Result, error) {
|
||||||
|
capturedReq = req
|
||||||
|
return iexec.Result{
|
||||||
|
Status: "pass",
|
||||||
|
Phase: "retrospective",
|
||||||
|
Skill: "retrospective",
|
||||||
|
Verified: true,
|
||||||
|
Message: "wrote 2 entries to brain",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
args, _ := json.Marshal(map[string]string{"session_id": "empty-session"})
|
||||||
|
out, err := s.Handle(context.Background(), "retrospective", args)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var result iexec.Result
|
||||||
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
|
assert.Equal(t, "pass", result.Status)
|
||||||
|
assert.Contains(t, capturedReq.SkillPrompt, "retrospective discipline")
|
||||||
|
assert.Contains(t, capturedReq.TaskPrompt, "empty-session")
|
||||||
|
}
|
||||||
50
internal/skills/retrospective/skill.go
Normal file
50
internal/skills/retrospective/skill.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// internal/skills/retrospective/skill.go
|
||||||
|
package retrospective
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecutorFn allows injecting a test double for the subprocess executor.
|
||||||
|
type ExecutorFn func(ctx context.Context, req iexec.Request) (iexec.Result, error)
|
||||||
|
|
||||||
|
// Config holds retrospective skill configuration.
|
||||||
|
type Config struct {
|
||||||
|
SkillPrompt string // content of retrospective.md
|
||||||
|
DefaultModel string // model to use when not specified in args
|
||||||
|
SessionsDir string // path to brain/sessions/
|
||||||
|
ExecutorFn ExecutorFn // injected executor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill implements registry.Skill for the retrospective tool.
|
||||||
|
type Skill struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a retrospective Skill.
|
||||||
|
func New(cfg Config) *Skill { return &Skill{cfg: cfg} }
|
||||||
|
|
||||||
|
// Name returns the skill name.
|
||||||
|
func (s *Skill) Name() string { return "retrospective" }
|
||||||
|
|
||||||
|
// Tools returns the MCP tool definitions.
|
||||||
|
func (s *Skill) Tools() []registry.ToolDef {
|
||||||
|
return []registry.ToolDef{
|
||||||
|
{
|
||||||
|
Name: "retrospective",
|
||||||
|
Description: "Run a retrospective on a completed session. Reads the session log, identifies novel learnings, and writes structured entries to the brain for ingestion. Call at the end of each coding session.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["session_id"],
|
||||||
|
"properties": {
|
||||||
|
"session_id": {"type": "string"},
|
||||||
|
"model": {"type": "string"}
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
58
internal/skills/sessionlog/handlers.go
Normal file
58
internal/skills/sessionlog/handlers.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// internal/skills/sessionlog/handlers.go
|
||||||
|
package sessionlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
type logArgs struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Skill string `json:"skill"`
|
||||||
|
Phase string `json:"phase,omitempty"`
|
||||||
|
ProjectRoot string `json:"project_root,omitempty"`
|
||||||
|
FinalStatus string `json:"final_status,omitempty"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
ModelUsed string `json:"model_used,omitempty"`
|
||||||
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dispatches the session_log tool call.
|
||||||
|
func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
if tool != "session_log" {
|
||||||
|
return nil, fmt.Errorf("unknown sessionlog tool: %s", tool)
|
||||||
|
}
|
||||||
|
var a logArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
if a.SessionID == "" {
|
||||||
|
return nil, fmt.Errorf("session_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := session.Entry{
|
||||||
|
SessionID: a.SessionID,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Skill: a.Skill,
|
||||||
|
Phase: a.Phase,
|
||||||
|
ProjectRoot: a.ProjectRoot,
|
||||||
|
FinalStatus: a.FinalStatus,
|
||||||
|
FilePath: a.FilePath,
|
||||||
|
ModelUsed: a.ModelUsed,
|
||||||
|
DurationMs: a.DurationMs,
|
||||||
|
Message: a.Message,
|
||||||
|
}
|
||||||
|
if err := session.Append(s.cfg.SessionsDir, a.SessionID, entry); err != nil {
|
||||||
|
return nil, fmt.Errorf("append session log: %w", err)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(map[string]string{"status": "ok", "session_id": a.SessionID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal response: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
44
internal/skills/sessionlog/handlers_test.go
Normal file
44
internal/skills/sessionlog/handlers_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// internal/skills/sessionlog/handlers_test.go
|
||||||
|
package sessionlog_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/sessionlog"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandle_SessionLog_AppendsEntry(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := sessionlog.New(sessionlog.Config{SessionsDir: dir})
|
||||||
|
|
||||||
|
args, _ := json.Marshal(map[string]any{
|
||||||
|
"session_id": "sess-abc",
|
||||||
|
"skill": "tdd_green",
|
||||||
|
"final_status": "pass",
|
||||||
|
"model_used": "ollama/qwen3",
|
||||||
|
"duration_ms": 3000,
|
||||||
|
})
|
||||||
|
out, err := s.Handle(context.Background(), "session_log", args)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var result map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
|
assert.Equal(t, "ok", result["status"])
|
||||||
|
|
||||||
|
// Verify file written
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "sess-abc.jsonl"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(data), "tdd_green")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandle_SessionLog_RequiresSessionID(t *testing.T) {
|
||||||
|
s := sessionlog.New(sessionlog.Config{SessionsDir: t.TempDir()})
|
||||||
|
args, _ := json.Marshal(map[string]any{"skill": "tdd_red"})
|
||||||
|
_, err := s.Handle(context.Background(), "session_log", args)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
49
internal/skills/sessionlog/skill.go
Normal file
49
internal/skills/sessionlog/skill.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// internal/skills/sessionlog/skill.go
|
||||||
|
package sessionlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds sessionlog skill configuration.
|
||||||
|
type Config struct {
|
||||||
|
SessionsDir string // path to brain/sessions/
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill implements registry.Skill for the session_log tool.
|
||||||
|
type Skill struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a sessionlog Skill.
|
||||||
|
func New(cfg Config) *Skill { return &Skill{cfg: cfg} }
|
||||||
|
|
||||||
|
// Name returns the skill name.
|
||||||
|
func (s *Skill) Name() string { return "sessionlog" }
|
||||||
|
|
||||||
|
// Tools returns the MCP tool definitions.
|
||||||
|
func (s *Skill) Tools() []registry.ToolDef {
|
||||||
|
return []registry.ToolDef{
|
||||||
|
{
|
||||||
|
Name: "session_log",
|
||||||
|
Description: "Append a structured entry to the current session log. Call after each skill invocation completes to record what happened for retrospective and training data extraction.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["session_id"],
|
||||||
|
"properties": {
|
||||||
|
"session_id": {"type": "string"},
|
||||||
|
"skill": {"type": "string"},
|
||||||
|
"phase": {"type": "string"},
|
||||||
|
"project_root": {"type": "string"},
|
||||||
|
"final_status": {"type": "string"},
|
||||||
|
"file_path": {"type": "string"},
|
||||||
|
"model_used": {"type": "string"},
|
||||||
|
"duration_ms": {"type": "integer"},
|
||||||
|
"message": {"type": "string"}
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/tier/tier.go
Normal file
64
internal/tier/tier.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// internal/tier/tier.go
|
||||||
|
package tier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tier represents the current operating capability level.
|
||||||
|
type Tier int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Full Tier = 1 // internet + Anthropic API reachable
|
||||||
|
LANOnly Tier = 2 // LiteLLM on LAN reachable, no internet
|
||||||
|
Airplane Tier = 3 // no network
|
||||||
|
)
|
||||||
|
|
||||||
|
// Info describes the current operating tier.
|
||||||
|
type Info struct {
|
||||||
|
Tier Tier `json:"tier"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
AvailableModels []string `json:"available_models"` // populated by callers as needed; Detect always returns nil
|
||||||
|
ManagedAgents bool `json:"managed_agents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect probes the Anthropic endpoint and LiteLLM and returns the current tier.
|
||||||
|
// Each probe has a 2-second timeout.
|
||||||
|
func Detect(ctx context.Context, anthropicProbe, liteLLMBaseURL string) Info {
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
|
||||||
|
if probe(ctx, client, anthropicProbe) {
|
||||||
|
return Info{
|
||||||
|
Tier: Full,
|
||||||
|
Label: "full-online",
|
||||||
|
ManagedAgents: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if probe(ctx, client, liteLLMBaseURL) {
|
||||||
|
return Info{
|
||||||
|
Tier: LANOnly,
|
||||||
|
Label: "lan-only",
|
||||||
|
ManagedAgents: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Info{
|
||||||
|
Tier: Airplane,
|
||||||
|
Label: "airplane",
|
||||||
|
ManagedAgents: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func probe(ctx context.Context, client *http.Client, url string) bool {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
48
internal/tier/tier_test.go
Normal file
48
internal/tier/tier_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// internal/tier/tier_test.go
|
||||||
|
package tier_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/tier"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetect_Tier1_WhenBothReachable(t *testing.T) {
|
||||||
|
anthropic := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer anthropic.Close()
|
||||||
|
|
||||||
|
litellm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer litellm.Close()
|
||||||
|
|
||||||
|
info := tier.Detect(context.Background(), anthropic.URL, litellm.URL)
|
||||||
|
assert.Equal(t, tier.Full, info.Tier)
|
||||||
|
assert.Equal(t, "full-online", info.Label)
|
||||||
|
assert.True(t, info.ManagedAgents)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetect_Tier2_WhenOnlyLiteLLMReachable(t *testing.T) {
|
||||||
|
litellm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer litellm.Close()
|
||||||
|
|
||||||
|
info := tier.Detect(context.Background(), "http://127.0.0.1:1", litellm.URL)
|
||||||
|
assert.Equal(t, tier.LANOnly, info.Tier)
|
||||||
|
assert.Equal(t, "lan-only", info.Label)
|
||||||
|
assert.False(t, info.ManagedAgents)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetect_Tier3_WhenNeitherReachable(t *testing.T) {
|
||||||
|
info := tier.Detect(context.Background(), "http://127.0.0.1:1", "http://127.0.0.1:2")
|
||||||
|
assert.Equal(t, tier.Airplane, info.Tier)
|
||||||
|
assert.Equal(t, "airplane", info.Label)
|
||||||
|
assert.False(t, info.ManagedAgents)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user