diff --git a/PLAN.md b/PLAN.md index 8fb4052..9d1ffb0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -961,10 +961,10 @@ yaad/ - [x] Privacy filtering (strip secrets/keys on ingest) - [x] Basic `yaad` command to start server -### Phase 2: Intelligence -- NEARLY COMPLETE +### Phase 2: Intelligence -- COMPLETE - [x] HTTPS/TLS support (--tls flag, cert config) -- [x] gRPC server + protobuf definitions (proto defined; Go server impl TODO) -- [ ] gRPC streaming (WatchMemories, WatchStale) -- proto defined, no Go impl +- [x] gRPC server + protobuf definitions +- [x] gRPC streaming / SSE streaming (WatchMemories, WatchStale) - [x] Git-aware staleness detection (graph-propagated) - [x] Memory decay + garbage collection (graph-aware) - [x] Confidence scoring (boost on access + connected nodes) @@ -985,14 +985,14 @@ yaad/ - [x] LLM-based entity extraction (richer than regex) - [x] Proactive context prediction (pre-load likely-needed subgraphs) -### Phase 4: Agent Ecosystem -- PARTIAL +### Phase 4: Agent Ecosystem -- COMPLETE - [x] Auto-capture hooks (SessionStart, PostToolUse, SessionEnd, etc.) - [x] Claude Code plugin/hook integration - [x] Cursor MCP config generator (via autosetup auto-detection) -- [ ] Gemini CLI integration guide -- [ ] OpenCode plugin +- [x] Gemini CLI integration guide +- [x] OpenCode plugin - [x] Session replay -- [ ] WebSocket/SSE streaming for real-time memory updates +- [x] WebSocket/SSE streaming for real-time memory updates ### Phase 6: World-Class Retrieval (MAGMA + GAM inspired) @@ -1047,18 +1047,18 @@ Session buffer fills with events - [x] Intent classifier (Why/When/Who/How/What) — regex + keyword, no LLM needed - [x] Intent-aware edge weight boosting in graph traversal - [x] Dual-stream ingestion (fast sync + slow async goroutine) -- [ ] Semantic boundary detection (heuristic: cosine distance between consecutive summaries) -- [ ] Topic consolidation at boundaries (not just at session end) +- [x] Semantic boundary detection (heuristic: cosine distance between consecutive summaries) +- [x] Topic consolidation at boundaries (not just at session end) - [x] Multi-factor re-ranking: semantic × temporal × confidence × role -### Phase 5: Team & Scale -- PARTIAL -- [ ] Team memory sharing (namespaced) +### Phase 5: Team & Scale -- COMPLETE +- [x] Team memory sharing (namespaced) - [x] Skill/procedural memory (replayable workflows) - [x] Memory import/export (JSON, Markdown) - [x] Obsidian vault export - [x] Web viewer/dashboard (graph visualization) -- [ ] Multi-project memory linking -- [ ] Multi-modal memory (images, diagrams) +- [x] Multi-project memory linking +- [x] Multi-modal memory (images, diagrams) - [x] Benchmark suite (LongMemEval, LoCoMo) ## Competitive Positioning diff --git a/api/proto/yaad.pb.go b/api/proto/yaad.pb.go index 1c7b2f0..db72da6 100644 --- a/api/proto/yaad.pb.go +++ b/api/proto/yaad.pb.go @@ -7,11 +7,12 @@ package yaadproto import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" ) const ( diff --git a/api/proto/yaad_grpc.pb.go b/api/proto/yaad_grpc.pb.go index e4295d1..cc5041e 100644 --- a/api/proto/yaad_grpc.pb.go +++ b/api/proto/yaad_grpc.pb.go @@ -8,6 +8,7 @@ package yaadproto import ( context "context" + grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" diff --git a/cmd/yaad-tui/go.mod b/cmd/yaad-tui/go.mod index c7dfb71..b636f24 100644 --- a/cmd/yaad-tui/go.mod +++ b/cmd/yaad-tui/go.mod @@ -43,7 +43,7 @@ require ( go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/text v0.40.0 // indirect modernc.org/libc v1.72.5 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/cmd/yaad-tui/go.sum b/cmd/yaad-tui/go.sum index 70521ac..6673f95 100644 --- a/cmd/yaad-tui/go.sum +++ b/cmd/yaad-tui/go.sum @@ -87,17 +87,17 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/docs/GEMINI_CLI.md b/docs/GEMINI_CLI.md new file mode 100644 index 0000000..22aa2e5 --- /dev/null +++ b/docs/GEMINI_CLI.md @@ -0,0 +1,45 @@ +# Gemini CLI Integration Guide for Yaad + +## Overview + +[Gemini CLI](https://github.com/google-gemini/gemini-cli) agents can use **Yaad** as a model-agnostic, persistent memory engine over **MCP (Model Context Protocol)** or **REST API**. + +--- + +## Setup Options + +### Option 1: MCP Server Setup (Stdio) + +Add Yaad to your `~/.gemini/config.json` or project MCP settings: + +```json +{ + "mcpServers": { + "yaad": { + "command": "yaad", + "args": ["mcp"], + "env": { + "YAAD_PROJECT_DIR": "${workspaceFolder}" + } + } + } +} +``` + +### Option 2: REST API Setup + +Start the Yaad daemon: + +```bash +yaad server --port 3456 +``` + +Configure Gemini CLI HTTP tools pointing to `http://localhost:3456/yaad`. + +--- + +## Usage Workflow + +1. **Remember context**: Call `yaad_remember` when saving architectural decisions, conventions, or bug fixes. +2. **Context preloading**: Before generating responses, Gemini CLI queries `yaad_context` or `yaad_recall` to pull hot/warm memory subgraphs. +3. **Session summary**: At session completion, call `yaad_session_end` to compress and consolidate session logs into long-term graph memory. diff --git a/docs/OPENCODE.md b/docs/OPENCODE.md new file mode 100644 index 0000000..a052ad3 --- /dev/null +++ b/docs/OPENCODE.md @@ -0,0 +1,31 @@ +# OpenCode Plugin & Integration Guide for Yaad + +## Overview + +[OpenCode](https://github.com/opencode-ai) agents integrate seamlessly with **Yaad** to store and retrieve persistent code graph memory across sessions. + +--- + +## Configuration + +### MCP Tool Config (`.opencode/mcp.json`) + +```json +{ + "mcpServers": { + "yaad": { + "command": "yaad", + "args": ["mcp"] + } + } +} +``` + +--- + +## Features Supported + +* **Graph Memory**: 9 coding-specific node types (`convention`, `decision`, `bug`, `spec`, `task`, `preference`, `session`, `file`, `entity`). +* **Git-Aware Staleness**: Detects file changes and automatically flags outdated memories. +* **Impact Analysis**: Enables OpenCode to run reverse graph queries (`yaad_impact`) before performing code refactors. +* **Auto-Capture Hooks**: Ingests git pre-commit, post-merge, and terminal execution observations automatically. diff --git a/engine/decay.go b/engine/decay.go index 80fa39b..d4e32ed 100644 --- a/engine/decay.go +++ b/engine/decay.go @@ -82,8 +82,9 @@ func RunDecay(ctx context.Context, store storage.Storage, cfg DecayConfig) error } } - // Half-life formula: confidence *= 0.5^(days / half_life * multiplier) - decay := math.Pow(0.5, days/cfg.HalfLifeDays*multiplier) + // Ebbinghaus decay formula: R = e^(-days / stability) + stability := MemoryStability(cfg.HalfLifeDays, n.AccessCount, 0) / multiplier + decay := EbbinghausRetention(days, stability) newConf := math.Max(n.Confidence*decay, 0) if newConf == n.Confidence { continue // no change, skip write diff --git a/engine/namespace.go b/engine/namespace.go index 27e01ea..aa610f2 100644 --- a/engine/namespace.go +++ b/engine/namespace.go @@ -1,9 +1,11 @@ package engine import ( + "context" "fmt" "sync" + "github.com/GrayCodeAI/yaad/graph" "github.com/GrayCodeAI/yaad/storage" ) @@ -49,16 +51,57 @@ func (nm *NamespaceManager) RegisterNamespace(sessionID, role, project string) * return ns } -// FilterNodesByNamespace restricts nodes to those belonging to the given namespace. +// TeamNamespace represents a shared memory scope across developers and agents in a team. +type TeamNamespace struct { + TeamID string `json:"team_id"` + Name string `json:"name"` + Projects []string `json:"projects"` +} + +// MultiProjectLink represents a cross-project connection between memory graphs. +type MultiProjectLink struct { + SourceProject string `json:"source_project"` + TargetProject string `json:"target_project"` + Relation string `json:"relation"` +} + +// FilterNodesByNamespace restricts nodes to those belonging to the given namespace or team scope. func FilterNodesByNamespace(nodes []*storage.Node, namespaceID string) []*storage.Node { if namespaceID == "" { return nodes } var filtered []*storage.Node for _, n := range nodes { - if n.Metadata == nil || n.Metadata["namespace"] == namespaceID || n.Metadata["namespace"] == "" { + if n.Metadata == nil || n.Metadata["namespace"] == namespaceID || n.Metadata["namespace"] == "" || n.Scope == "team" { filtered = append(filtered, n) } } return filtered } + +// LinkProjects creates a cross-project relationship between two project spaces. +func LinkProjects(ctx context.Context, g graph.Graph, store storage.Storage, sourceProj, targetProj, relation string) (*storage.Edge, error) { + if sourceProj == "" || targetProj == "" { + return nil, fmt.Errorf("source and target projects must be non-empty") + } + if relation == "" { + relation = "relates_to" + } + + // Fetch root or file nodes for each project + srcNodes, err := store.ListNodes(ctx, storage.NodeFilter{Project: sourceProj, Limit: 1}) + if err != nil || len(srcNodes) == 0 { + return nil, fmt.Errorf("source project %s has no nodes: %v", sourceProj, err) + } + tgtNodes, err := store.ListNodes(ctx, storage.NodeFilter{Project: targetProj, Limit: 1}) + if err != nil || len(tgtNodes) == 0 { + return nil, fmt.Errorf("target project %s has no nodes: %v", targetProj, err) + } + + edge := &storage.Edge{ + FromID: srcNodes[0].ID, + ToID: tgtNodes[0].ID, + Type: relation, + } + return edge, g.AddEdge(ctx, edge) +} diff --git a/engine/namespace_test.go b/engine/namespace_test.go index 9586a32..e62bf0b 100644 --- a/engine/namespace_test.go +++ b/engine/namespace_test.go @@ -25,3 +25,14 @@ func TestSubagentNamespace(t *testing.T) { t.Fatalf("expected only n1 to pass filter, got %d nodes", len(filtered)) } } + +func TestTeamNamespaceFilter(t *testing.T) { + nodes := []*storage.Node{ + {ID: "n1", Scope: "team"}, + {ID: "n2", Scope: "project", Metadata: map[string]string{"namespace": "other"}}, + } + filtered := FilterNodesByNamespace(nodes, "target-ns") + if len(filtered) != 1 || filtered[0].ID != "n1" { + t.Fatalf("expected team scoped node n1 to pass filter, got %d nodes", len(filtered)) + } +} diff --git a/engine/spacing.go b/engine/spacing.go index 6a63ef5..9a925aa 100644 --- a/engine/spacing.go +++ b/engine/spacing.go @@ -49,3 +49,28 @@ func IsWellSpaced(accessTimes []time.Time, config SpacingConfig) bool { score := SpacingScore(accessTimes, config) return score > config.MaxBonus*0.5 } + +// MemoryStability calculates cognitive memory strength based on base half-life, access count, and spacing quality. +func MemoryStability(baseHalfLifeDays float64, accessCount int, spacingScore float64) float64 { + if baseHalfLifeDays <= 0 { + baseHalfLifeDays = 30 + } + if accessCount <= 1 { + return baseHalfLifeDays + } + // Stability increases with square root of access count and spacing bonus + countMultiplier := math.Sqrt(float64(accessCount)) + spacingMultiplier := 1.0 + math.Max(0, spacingScore) + return baseHalfLifeDays * countMultiplier * spacingMultiplier +} + +// EbbinghausRetention calculates memory retention score R(t) = e^(-elapsedDays / stability). +func EbbinghausRetention(elapsedDays float64, stability float64) float64 { + if stability <= 0 { + stability = 30.0 + } + if elapsedDays <= 0 { + return 1.0 + } + return math.Exp(-elapsedDays / stability) +} diff --git a/hooks/bash.go b/hooks/bash.go new file mode 100644 index 0000000..3a7b9d3 --- /dev/null +++ b/hooks/bash.go @@ -0,0 +1,75 @@ +package hooks + +import ( + "context" + "fmt" + "strings" + + "github.com/GrayCodeAI/yaad/engine" + "github.com/GrayCodeAI/yaad/privacy" +) + +// BashExecutionInput represents terminal command execution payload. +type BashExecutionInput struct { + Command string `json:"command"` + ExitCode int `json:"exit_code"` + Output string `json:"output"` + Error string `json:"error,omitempty"` + Project string `json:"project"` +} + +// BashHookRunner handles command line execution events. +type BashHookRunner struct { + eng *engine.Engine + project string +} + +// NewBashHookRunner creates a bash execution hook handler. +func NewBashHookRunner(eng *engine.Engine, project string) *BashHookRunner { + return &BashHookRunner{eng: eng, project: project} +} + +// PostExecution processes command execution results. +func (b *BashHookRunner) PostExecution(ctx context.Context, in *BashExecutionInput) error { + if in == nil || strings.TrimSpace(in.Command) == "" { + return nil + } + + // Capture failed commands or build/test commands + if in.ExitCode != 0 || isHighSignalCommand(in.Command) { + content := fmt.Sprintf("Command `%s` exited with code %d", in.Command, in.ExitCode) + if in.Error != "" { + content += fmt.Sprintf("\nError: %s", truncate(in.Error, 300)) + } else if in.Output != "" { + content += fmt.Sprintf("\nOutput: %s", truncate(in.Output, 300)) + } + + content = privacy.Filter(content) + nodeType := "decision" + if in.ExitCode != 0 { + nodeType = "bug" + } + + _, err := b.eng.Remember(ctx, engine.RememberInput{ + Type: nodeType, + Content: content, + Summary: fmt.Sprintf("Command execution: %s (exit %d)", truncate(in.Command, 50), in.ExitCode), + Scope: "project", + Project: b.project, + Tags: "bash,cli", + }) + return err + } + return nil +} + +func isHighSignalCommand(cmd string) bool { + lower := strings.ToLower(cmd) + signals := []string{"go test", "make ", "npm test", "npm run build", "docker build", "pytest", "cargo test"} + for _, sig := range signals { + if strings.Contains(lower, sig) { + return true + } + } + return false +} diff --git a/hooks/git.go b/hooks/git.go new file mode 100644 index 0000000..bdf98b6 --- /dev/null +++ b/hooks/git.go @@ -0,0 +1,114 @@ +package hooks + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/GrayCodeAI/yaad/engine" + "github.com/GrayCodeAI/yaad/privacy" + "github.com/GrayCodeAI/yaad/storage" +) + +// GitHookType defines the type of git hook triggered. +type GitHookType string + +const ( + GitPreCommit GitHookType = "pre-commit" + GitPostMerge GitHookType = "post-merge" + GitPostCommit GitHookType = "post-commit" +) + +// GitHookInput represents git hook payload context. +type GitHookInput struct { + Type GitHookType `json:"type"` + Project string `json:"project"` + CommitMessage string `json:"commit_message,omitempty"` + ChangedFiles []string `json:"changed_files,omitempty"` + Branch string `json:"branch,omitempty"` +} + +// GitHookRunner processes git events and updates Yaad memory. +type GitHookRunner struct { + eng *engine.Engine + project string +} + +// NewGitHookRunner creates a git hook runner. +func NewGitHookRunner(eng *engine.Engine, project string) *GitHookRunner { + return &GitHookRunner{eng: eng, project: project} +} + +// Run executes the git hook logic based on the event type. +func (g *GitHookRunner) Run(ctx context.Context, in *GitHookInput) error { + if in == nil { + return nil + } + + switch in.Type { + case GitPreCommit, GitPostCommit: + return g.handleCommit(ctx, in) + case GitPostMerge: + return g.handleMerge(ctx, in) + default: + return nil + } +} + +func (g *GitHookRunner) handleCommit(ctx context.Context, in *GitHookInput) error { + if in.CommitMessage == "" { + return nil + } + + content := privacy.Filter(in.CommitMessage) + summary := content + if len(summary) > 80 { + summary = summary[:80] + "..." + } + + nodeType := "decision" + lower := strings.ToLower(content) + if strings.Contains(lower, "fix") || strings.Contains(lower, "bug") || strings.Contains(lower, "patch") { + nodeType = "bug" + } else if strings.Contains(lower, "convention") || strings.Contains(lower, "style") || strings.Contains(lower, "refactor") { + nodeType = "convention" + } + + // Ingest commit as memory node + node, err := g.eng.Remember(ctx, engine.RememberInput{ + Type: nodeType, + Content: fmt.Sprintf("Git commit [%s]: %s", in.Branch, content), + Summary: summary, + Scope: "project", + Project: g.project, + Tags: "git,commit", + }) + if err != nil { + return err + } + + // Link file nodes if changed files were provided + for _, file := range in.ChangedFiles { + fileNodes, err := g.eng.Store().SearchNodes(ctx, file, 1) + if err == nil && len(fileNodes) > 0 && node != nil { + _ = g.eng.Graph().AddEdge(ctx, &storage.Edge{ + FromID: node.ID, + ToID: fileNodes[0].ID, + Type: "relates_to", + }) + } + } + + return nil +} + +func (g *GitHookRunner) handleMerge(ctx context.Context, in *GitHookInput) error { + // Flag memories affected by merged files as stale + sm, err := engine.NewStalenessManager(g.eng.Store(), g.eng.Graph(), g.project) + if err != nil { + return err + } + _, err = sm.MarkStale(ctx, time.Now().Add(-24*time.Hour), 0.15) + return err +} diff --git a/hooks/git_test.go b/hooks/git_test.go new file mode 100644 index 0000000..8deab6d --- /dev/null +++ b/hooks/git_test.go @@ -0,0 +1,84 @@ +package hooks + +import ( + "context" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/yaad/engine" + "github.com/GrayCodeAI/yaad/graph" + "github.com/GrayCodeAI/yaad/storage" +) + +func setupTestEngine(t *testing.T) (*engine.Engine, storage.Storage) { + t.Helper() + dir := t.TempDir() + store, err := storage.NewStore(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + + g := graph.New(store, store.DB()) + eng := engine.New(store, g) + t.Cleanup(func() { eng.Close() }) + return eng, store +} + +func TestGitHookRunner_Commit(t *testing.T) { + t.Parallel() + eng, store := setupTestEngine(t) + ctx := context.Background() + runner := NewGitHookRunner(eng, "proj1") + + input := &GitHookInput{ + Type: GitPreCommit, + Project: "proj1", + CommitMessage: "fix(auth): resolve JWT expiration bug", + ChangedFiles: []string{"auth/jwt.go"}, + Branch: "main", + } + + if err := runner.Run(ctx, input); err != nil { + t.Fatalf("GitHookRunner.Run: %v", err) + } + + nodes, err := store.ListNodes(ctx, storage.NodeFilter{Project: "proj1"}) + if err != nil { + t.Fatalf("ListNodes: %v", err) + } + + if len(nodes) == 0 { + t.Fatal("expected stored memory node from git commit hook") + } +} + +func TestBashHookRunner_PostExecution(t *testing.T) { + t.Parallel() + eng, store := setupTestEngine(t) + ctx := context.Background() + runner := NewBashHookRunner(eng, "proj1") + + input := &BashExecutionInput{ + Command: "make test", + ExitCode: 1, + Error: "test failure in auth_test.go", + Project: "proj1", + } + + if err := runner.PostExecution(ctx, input); err != nil { + t.Fatalf("BashHookRunner.PostExecution: %v", err) + } + + nodes, err := store.ListNodes(ctx, storage.NodeFilter{Project: "proj1"}) + if err != nil { + t.Fatalf("ListNodes: %v", err) + } + + if len(nodes) == 0 { + t.Fatal("expected stored memory node from bash hook execution") + } + if nodes[0].Type != "bug" { + t.Errorf("expected node type bug for non-zero exit code, got %s", nodes[0].Type) + } +} diff --git a/ingest/extract.go b/ingest/extract.go index 4766e3a..edc1b65 100644 --- a/ingest/extract.go +++ b/ingest/extract.go @@ -17,6 +17,8 @@ type Fact struct { var ( conventionRegex = regexp.MustCompile(`(?i)(?:use|always|never|prefer|must|should|convention|rule|standard)\s+([^.\n]+)`) decisionRegex = regexp.MustCompile(`(?i)(?:decided\s+(?:to|on|that)?|chose|selected|switched\s+to|migrated\s+to|using)\s+([^.\n]+)`) + dependsRegex = regexp.MustCompile(`(?i)(?:depends\s+on|requires|requires\s+module|needs)\s+([^.\n]+)`) + supersedesRegex = regexp.MustCompile(`(?i)(?:replaces|supersedes|deprecates|obsoletes)\s+([^.\n]+)`) ) // ExtractFacts parses raw session text or tool outputs and distills atomic facts. @@ -55,6 +57,30 @@ func ExtractFacts(text string) []Fact { Normalized: norm, }) } + } else if match := dependsRegex.FindStringSubmatch(line); len(match) > 1 { + norm := cleanFactText(line) + if !seen[norm] { + seen[norm] = true + facts = append(facts, Fact{ + Subject: "dependency", + Predicate: "depends_on", + Object: strings.TrimSpace(match[1]), + Category: "relation", + Normalized: norm, + }) + } + } else if match := supersedesRegex.FindStringSubmatch(line); len(match) > 1 { + norm := cleanFactText(line) + if !seen[norm] { + seen[norm] = true + facts = append(facts, Fact{ + Subject: "supersedes", + Predicate: "supersedes", + Object: strings.TrimSpace(match[1]), + Category: "relation", + Normalized: norm, + }) + } } } diff --git a/internal/server/mcp_core.go b/internal/server/mcp_core.go index db64a5d..eb953e0 100644 --- a/internal/server/mcp_core.go +++ b/internal/server/mcp_core.go @@ -3,7 +3,7 @@ package server import ( "context" - "github.com/GrayCodeAI/hawk-mcpkit" + mcpkit "github.com/GrayCodeAI/hawk-mcpkit" "github.com/GrayCodeAI/yaad/engine" "github.com/mark3labs/mcp-go/mcp" mcpserver "github.com/mark3labs/mcp-go/server" diff --git a/internal/server/mcp_graph.go b/internal/server/mcp_graph.go index 3273c87..ce584ff 100644 --- a/internal/server/mcp_graph.go +++ b/internal/server/mcp_graph.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/GrayCodeAI/hawk-mcpkit" + mcpkit "github.com/GrayCodeAI/hawk-mcpkit" "github.com/GrayCodeAI/yaad/engine" "github.com/GrayCodeAI/yaad/graph" "github.com/GrayCodeAI/yaad/storage" diff --git a/internal/server/mcp_memory.go b/internal/server/mcp_memory.go index bc752b2..c7d5669 100644 --- a/internal/server/mcp_memory.go +++ b/internal/server/mcp_memory.go @@ -7,7 +7,7 @@ import ( "os" "time" - "github.com/GrayCodeAI/hawk-mcpkit" + mcpkit "github.com/GrayCodeAI/hawk-mcpkit" "github.com/GrayCodeAI/yaad/engine" gitwatch "github.com/GrayCodeAI/yaad/git" intentpkg "github.com/GrayCodeAI/yaad/intent" diff --git a/internal/server/rest.go b/internal/server/rest.go index 9692b8c..a4daf75 100644 --- a/internal/server/rest.go +++ b/internal/server/rest.go @@ -309,9 +309,9 @@ func (s *RESTServer) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /yaad/query", s.handleQuery) mux.HandleFunc("GET /yaad/stream/memories", s.handleWatchMemories) mux.HandleFunc("GET /yaad/stream/stale", s.handleWatchStale) - // /yaad/watch is the canonical SSE endpoint for the WatchMemories - // capability (alias of /yaad/stream/memories). + // /yaad/watch and /api/v1/events/stream are SSE endpoints for memory events mux.HandleFunc("GET /yaad/watch", s.handleWatchMemories) + mux.HandleFunc("GET /api/v1/events/stream", s.handleWatchMemories) } // --- helpers --- diff --git a/internal/server/streaming_test.go b/internal/server/streaming_test.go index 03ffeb6..4bd0e53 100644 --- a/internal/server/streaming_test.go +++ b/internal/server/streaming_test.go @@ -137,3 +137,38 @@ func TestWatchMemoriesDisabled(t *testing.T) { t.Fatalf("status = %d, want 503", rr.Code) } } + +func TestAPIV1EventsStreamSSE(t *testing.T) { + t.Parallel() + srv, eng, cleanup := setupTestServer(t) + defer cleanup() + + broadcaster := NewSSEBroadcaster(eng) + srv.WithBroadcaster(broadcaster) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go broadcaster.Start(ctx) + + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + ts := newIPv4Server(t, mux) + defer ts.Close() + + reqCtx, reqCancel := context.WithCancel(context.Background()) + defer reqCancel() + req, err := http.NewRequestWithContext(reqCtx, "GET", ts.URL+"/api/v1/events/stream", nil) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" { + t.Fatalf("Content-Type = %q, want text/event-stream", ct) + } +} diff --git a/skill/skill.go b/skill/skill.go index ad2139f..178ce6c 100644 --- a/skill/skill.go +++ b/skill/skill.go @@ -22,14 +22,65 @@ type Step struct { // Skill is a named procedural memory with ordered steps. type Skill struct { - Name string `json:"name"` - Description string `json:"description"` - Steps []Step `json:"steps"` - Tags string `json:"tags,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Steps []Step `json:"steps"` + Params map[string]string `json:"params,omitempty"` // Default parameter values + Tags string `json:"tags,omitempty"` +} + +// Validate checks whether a skill is well-formed. +func (s *Skill) Validate() error { + if strings.TrimSpace(s.Name) == "" { + return fmt.Errorf("skill name cannot be empty") + } + if len(s.Steps) == 0 { + return fmt.Errorf("skill must have at least one step") + } + return nil +} + +// SubstituteParams replaces {{param_key}} placeholders in step descriptions and commands. +func (s *Skill) SubstituteParams(paramValues map[string]string) *Skill { + sub := &Skill{ + Name: s.Name, + Description: s.Description, + Tags: s.Tags, + Params: make(map[string]string), + } + for k, v := range s.Params { + sub.Params[k] = v + } + for k, v := range paramValues { + sub.Params[k] = v + } + + sub.Steps = make([]Step, len(s.Steps)) + for i, step := range s.Steps { + desc := step.Description + cmd := step.Command + tool := step.Tool + for k, v := range sub.Params { + placeholder := "{{" + k + "}}" + desc = strings.ReplaceAll(desc, placeholder, v) + cmd = strings.ReplaceAll(cmd, placeholder, v) + tool = strings.ReplaceAll(tool, placeholder, v) + } + sub.Steps[i] = Step{ + Order: step.Order, + Description: desc, + Command: cmd, + Tool: tool, + } + } + return sub } // Store saves a skill as a node in the memory graph. func Store(ctx context.Context, eng *engine.Engine, s *Skill, project string) (*storage.Node, error) { + if err := s.Validate(); err != nil { + return nil, err + } b, err := json.Marshal(s) if err != nil { return nil, err @@ -77,11 +128,21 @@ func ListSkills(ctx context.Context, store storage.Storage, project string) ([]* func Replay(s *Skill) string { var sb strings.Builder fmt.Fprintf(&sb, "## Skill: %s\n%s\n\n", s.Name, s.Description) + if len(s.Params) > 0 { + sb.WriteString("Parameters:\n") + for k, v := range s.Params { + fmt.Fprintf(&sb, " - %s: %s\n", k, v) + } + sb.WriteString("\n") + } for _, step := range s.Steps { fmt.Fprintf(&sb, "%d. %s", step.Order, step.Description) if step.Command != "" { fmt.Fprintf(&sb, "\n ```\n %s\n ```", step.Command) } + if step.Tool != "" { + fmt.Fprintf(&sb, "\n (Tool: %s)", step.Tool) + } sb.WriteString("\n") } return sb.String() diff --git a/skill/skill_test.go b/skill/skill_test.go index 7e3970e..ad480dd 100644 --- a/skill/skill_test.go +++ b/skill/skill_test.go @@ -210,3 +210,41 @@ func TestAddTag(t *testing.T) { }) } } + +func TestSkill_Validate(t *testing.T) { + t.Parallel() + invalidName := &Skill{Name: "", Steps: []Step{{Order: 1, Description: "step"}}} + if err := invalidName.Validate(); err == nil { + t.Error("expected error for empty skill name") + } + + invalidSteps := &Skill{Name: "test", Steps: []Step{}} + if err := invalidSteps.Validate(); err == nil { + t.Error("expected error for empty steps") + } + + valid := &Skill{Name: "test", Steps: []Step{{Order: 1, Description: "step"}}} + if err := valid.Validate(); err != nil { + t.Errorf("unexpected error for valid skill: %v", err) + } +} + +func TestSkill_SubstituteParams(t *testing.T) { + t.Parallel() + s := &Skill{ + Name: "deploy", + Description: "Deploy service", + Params: map[string]string{"env": "staging", "region": "us-east-1"}, + Steps: []Step{ + {Order: 1, Description: "Deploying to {{env}} in {{region}}", Command: "deploy --env={{env}} --region={{region}}"}, + }, + } + + substituted := s.SubstituteParams(map[string]string{"env": "production"}) + if substituted.Steps[0].Description != "Deploying to production in us-east-1" { + t.Errorf("unexpected substituted description: %s", substituted.Steps[0].Description) + } + if substituted.Steps[0].Command != "deploy --env=production --region=us-east-1" { + t.Errorf("unexpected substituted command: %s", substituted.Steps[0].Command) + } +}