From 155230fb5b8daf0dd577bfa062cba2ea2adae704 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 11:04:36 +0000 Subject: [PATCH] feat(agentic): add brain seed-memory command Co-Authored-By: Virgil --- pkg/agentic/brain_seed_memory.go | 309 ++++++++++++++++++++++++++ pkg/agentic/brain_seed_memory_test.go | 127 +++++++++++ pkg/agentic/commands.go | 1 + pkg/agentic/prep_test.go | 1 + 4 files changed, 438 insertions(+) create mode 100644 pkg/agentic/brain_seed_memory.go create mode 100644 pkg/agentic/brain_seed_memory_test.go diff --git a/pkg/agentic/brain_seed_memory.go b/pkg/agentic/brain_seed_memory.go new file mode 100644 index 0000000..e77c48a --- /dev/null +++ b/pkg/agentic/brain_seed_memory.go @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "sort" + + core "dappco.re/go/core" +) + +const brainSeedMemoryDefaultAgent = "virgil" + +const brainSeedMemoryDefaultPath = "~/.claude/projects/*/memory/" + +type BrainSeedMemoryInput struct { + WorkspaceID int + AgentID string + Path string + DryRun bool +} + +type BrainSeedMemoryOutput struct { + Success bool `json:"success"` + WorkspaceID int `json:"workspace_id,omitempty"` + AgentID string `json:"agent_id,omitempty"` + Path string `json:"path,omitempty"` + Files int `json:"files,omitempty"` + Imported int `json:"imported,omitempty"` + Skipped int `json:"skipped,omitempty"` + DryRun bool `json:"dry_run,omitempty"` +} + +type brainSeedMemorySection struct { + Heading string + Content string +} + +// result := c.Command("brain/seed-memory").Run(ctx, core.NewOptions( +// +// core.Option{Key: "workspace", Value: "1"}, +// core.Option{Key: "path", Value: "/Users/snider/.claude/projects/*/memory/"}, +// +// )) +func (s *PrepSubsystem) cmdBrainSeedMemory(options core.Options) core.Result { + input := BrainSeedMemoryInput{ + WorkspaceID: parseIntString(optionStringValue(options, "workspace", "workspace_id", "workspace-id", "_arg")), + AgentID: optionStringValue(options, "agent", "agent_id", "agent-id"), + Path: optionStringValue(options, "path"), + DryRun: optionBoolValue(options, "dry-run"), + } + if input.WorkspaceID == 0 { + core.Print(nil, "usage: core-agent brain seed-memory --workspace=1 [--agent=virgil] [--path=~/.claude/projects/*/memory/] [--dry-run]") + return core.Result{Value: core.E("agentic.cmdBrainSeedMemory", "workspace is required", nil), OK: false} + } + if input.AgentID == "" { + input.AgentID = brainSeedMemoryDefaultAgent + } + if input.Path == "" { + input.Path = brainSeedMemoryDefaultPath + } + + result := s.brainSeedMemory(s.commandContext(), input) + if !result.OK { + err := commandResultError("agentic.cmdBrainSeedMemory", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(BrainSeedMemoryOutput) + if !ok { + err := core.E("agentic.cmdBrainSeedMemory", "invalid brain seed memory output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + if output.Files == 0 { + core.Print(nil, "No markdown files found in: %s", output.Path) + return core.Result{Value: output, OK: true} + } + + prefix := "" + if output.DryRun { + prefix = "[DRY RUN] " + } + core.Print(nil, "%sImported %d memories, skipped %d.", prefix, output.Imported, output.Skipped) + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) brainSeedMemory(ctx context.Context, input BrainSeedMemoryInput) core.Result { + if s.brainKey == "" { + return core.Result{Value: core.E("agentic.brainSeedMemory", "no brain API key configured", nil), OK: false} + } + + scanPath := brainSeedMemoryScanPath(input.Path) + files := brainSeedMemoryFiles(scanPath) + output := BrainSeedMemoryOutput{ + Success: true, + WorkspaceID: input.WorkspaceID, + AgentID: input.AgentID, + Path: scanPath, + Files: len(files), + DryRun: input.DryRun, + } + + if len(files) == 0 { + return core.Result{Value: output, OK: true} + } + + for _, path := range files { + readResult := fs.Read(path) + if !readResult.OK { + output.Skipped++ + continue + } + + sections := brainSeedMemorySections(readResult.Value.(string)) + if len(sections) == 0 { + output.Skipped++ + core.Print(nil, " Skipped %s (no sections found)", core.PathBase(path)) + continue + } + + project := brainSeedMemoryProject(path) + filename := core.TrimSuffix(core.PathBase(path), ".md") + + for _, section := range sections { + memoryType := brainSeedMemoryType(section.Heading, section.Content) + if input.DryRun { + core.Print(nil, " [DRY RUN] %s :: %s (%s) — %d chars", core.PathBase(path), section.Heading, memoryType, core.RuneCount(section.Content)) + output.Imported++ + continue + } + + body := map[string]any{ + "workspace_id": input.WorkspaceID, + "agent_id": input.AgentID, + "type": memoryType, + "content": core.Concat(section.Heading, "\n\n", section.Content), + "tags": brainSeedMemoryTags(filename), + "project": project, + "confidence": 0.7, + } + + if result := HTTPPost(ctx, core.Concat(s.brainURL, "/v1/brain/remember"), core.JSONMarshalString(body), s.brainKey, "Bearer"); !result.OK { + output.Skipped++ + core.Print(nil, " Failed to import %s :: %s", core.PathBase(path), section.Heading) + continue + } + + output.Imported++ + } + } + + return core.Result{Value: output, OK: true} +} + +func brainSeedMemoryScanPath(path string) string { + trimmed := brainSeedMemoryExpandHome(core.Trim(path)) + if trimmed == "" { + return brainSeedMemoryExpandHome(brainSeedMemoryDefaultPath) + } + if fs.IsFile(trimmed) || core.HasSuffix(trimmed, ".md") { + return trimmed + } + return core.JoinPath(trimmed, "*.md") +} + +func brainSeedMemoryExpandHome(path string) string { + if core.HasPrefix(path, "~/") { + return core.Concat(HomeDir(), core.TrimPrefix(path, "~")) + } + return path +} + +func brainSeedMemoryFiles(scanPath string) []string { + if scanPath == "" { + return nil + } + if fs.IsFile(scanPath) { + return []string{scanPath} + } + + files := core.PathGlob(scanPath) + sort.Strings(files) + return files +} + +func brainSeedMemorySections(content string) []brainSeedMemorySection { + lines := core.Split(content, "\n") + var sections []brainSeedMemorySection + + currentHeading := "" + var currentContent []string + + flush := func() { + if currentHeading == "" || len(currentContent) == 0 { + return + } + sectionContent := core.Trim(core.Join("\n", currentContent...)) + if sectionContent == "" { + return + } + sections = append(sections, brainSeedMemorySection{ + Heading: currentHeading, + Content: sectionContent, + }) + } + + for _, line := range lines { + if heading, ok := brainSeedMemoryHeading(line); ok { + flush() + currentHeading = heading + currentContent = currentContent[:0] + continue + } + if currentHeading == "" { + continue + } + currentContent = append(currentContent, line) + } + + flush() + return sections +} + +func brainSeedMemoryHeading(line string) (string, bool) { + trimmed := core.Trim(line) + if trimmed == "" || !core.HasPrefix(trimmed, "#") { + return "", false + } + + hashes := 0 + for _, r := range trimmed { + if r != '#' { + break + } + hashes++ + } + + if hashes < 1 || hashes > 3 || len(trimmed) <= hashes || trimmed[hashes] != ' ' { + return "", false + } + + heading := core.Trim(trimmed[hashes:]) + if heading == "" { + return "", false + } + return heading, true +} + +func brainSeedMemoryType(heading, content string) string { + lower := core.Lower(core.Concat(heading, " ", content)) + for _, candidate := range []struct { + memoryType string + keywords []string + }{ + {memoryType: "architecture", keywords: []string{"architecture", "stack", "infrastructure", "layer", "service mesh"}}, + {memoryType: "convention", keywords: []string{"convention", "standard", "naming", "pattern", "rule", "coding"}}, + {memoryType: "decision", keywords: []string{"decision", "chose", "strategy", "approach", "domain"}}, + {memoryType: "bug", keywords: []string{"bug", "fix", "broken", "error", "issue", "lesson"}}, + {memoryType: "plan", keywords: []string{"plan", "todo", "roadmap", "milestone", "phase"}}, + {memoryType: "research", keywords: []string{"research", "finding", "discovery", "analysis", "rfc"}}, + } { + for _, keyword := range candidate.keywords { + if core.Contains(lower, keyword) { + return candidate.memoryType + } + } + } + return "observation" +} + +func brainSeedMemoryTags(filename string) []string { + if filename == "" { + return []string{"memory-import"} + } + + tags := []string{} + if core.Lower(filename) != "memory" { + tag := core.Replace(core.Replace(filename, "-", " "), "_", " ") + if tag != "" { + tags = append(tags, tag) + } + } + tags = append(tags, "memory-import") + return tags +} + +func brainSeedMemoryProject(path string) string { + normalised := core.Replace(path, "\\", "/") + segments := core.Split(normalised, "/") + for i := 1; i < len(segments); i++ { + if segments[i] != "memory" { + continue + } + projectSegment := segments[i-1] + if projectSegment == "" { + continue + } + chunks := core.Split(projectSegment, "-") + for j := len(chunks) - 1; j >= 0; j-- { + if chunks[j] != "" { + return chunks[j] + } + } + } + return "" +} diff --git a/pkg/agentic/brain_seed_memory_test.go b/pkg/agentic/brain_seed_memory_test.go new file mode 100644 index 0000000..b7653a1 --- /dev/null +++ b/pkg/agentic/brain_seed_memory_test.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBrainSeedMemory_CmdBrainSeedMemory_Good(t *testing.T) { + home := t.TempDir() + t.Setenv("CORE_HOME", home) + + memoryDir := core.JoinPath(home, ".claude", "projects", "-Users-snider-Code-eaas", "memory") + require.True(t, fs.EnsureDir(memoryDir).OK) + require.True(t, fs.Write(core.JoinPath(memoryDir, "MEMORY.md"), "# Memory\n\n## Architecture\nUse Core.Process().\n\n## Decision\nPrefer named actions.").OK) + require.True(t, fs.Write(core.JoinPath(memoryDir, "notes.md"), "## Convention\nUse UK English.\n").OK) + + var bodies []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/brain/remember", r.URL.Path) + bodyResult := core.ReadAll(r.Body) + require.True(t, bodyResult.OK) + var payload map[string]any + require.True(t, core.JSONUnmarshalString(bodyResult.Value.(string), &payload).OK) + bodies = append(bodies, payload) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"memory":{"id":"mem-1"}}`)) + })) + defer srv.Close() + + subsystem := &PrepSubsystem{ + brainURL: srv.URL, + brainKey: "brain-key", + } + + result := subsystem.cmdBrainSeedMemory(core.NewOptions( + core.Option{Key: "workspace", Value: "42"}, + core.Option{Key: "path", Value: memoryDir}, + core.Option{Key: "agent", Value: "virgil"}, + )) + + require.True(t, result.OK) + output, ok := result.Value.(BrainSeedMemoryOutput) + require.True(t, ok) + assert.Equal(t, 2, output.Files) + assert.Equal(t, 3, output.Imported) + assert.Equal(t, 0, output.Skipped) + assert.Equal(t, false, output.DryRun) + assert.Equal(t, core.JoinPath(memoryDir, "*.md"), output.Path) + require.Len(t, bodies, 3) + + assert.Equal(t, float64(42), bodies[0]["workspace_id"]) + assert.Equal(t, "virgil", bodies[0]["agent_id"]) + assert.Equal(t, "architecture", bodies[0]["type"]) + assert.Equal(t, "eaas", bodies[0]["project"]) + assert.Contains(t, bodies[0]["content"].(string), "Architecture") + assert.Equal(t, []any{"memory-import"}, bodies[0]["tags"]) + + assert.Equal(t, "decision", bodies[1]["type"]) + assert.Equal(t, []any{"memory-import"}, bodies[1]["tags"]) + + assert.Equal(t, "convention", bodies[2]["type"]) + assert.Equal(t, []any{"notes", "memory-import"}, bodies[2]["tags"]) +} + +func TestBrainSeedMemory_CmdBrainSeedMemory_Bad_MissingWorkspace(t *testing.T) { + subsystem := &PrepSubsystem{brainURL: "https://example.com", brainKey: "brain-key"} + + result := subsystem.cmdBrainSeedMemory(core.NewOptions( + core.Option{Key: "path", Value: "/tmp/memory"}, + )) + + require.False(t, result.OK) + err, ok := result.Value.(error) + require.True(t, ok) + assert.Contains(t, err.Error(), "workspace is required") +} + +func TestBrainSeedMemory_CmdBrainSeedMemory_Ugly_PartialImportFailure(t *testing.T) { + home := t.TempDir() + t.Setenv("CORE_HOME", home) + + memoryDir := core.JoinPath(home, ".claude", "projects", "-Users-snider-Code-eaas", "memory") + require.True(t, fs.EnsureDir(memoryDir).OK) + require.True(t, fs.Write(core.JoinPath(memoryDir, "MEMORY.md"), "## Architecture\nUse Core.Process().\n\n## Decision\nPrefer named actions.").OK) + + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + bodyResult := core.ReadAll(r.Body) + require.True(t, bodyResult.OK) + var payload map[string]any + require.True(t, core.JSONUnmarshalString(bodyResult.Value.(string), &payload).OK) + if calls == 1 { + http.Error(w, "boom", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"memory":{"id":"mem-2"}}`)) + })) + defer srv.Close() + + subsystem := &PrepSubsystem{ + brainURL: srv.URL, + brainKey: "brain-key", + } + + result := subsystem.brainSeedMemory(context.Background(), BrainSeedMemoryInput{ + WorkspaceID: 42, + AgentID: "virgil", + Path: memoryDir, + }) + + require.True(t, result.OK) + output, ok := result.Value.(BrainSeedMemoryOutput) + require.True(t, ok) + assert.Equal(t, 1, output.Imported) + assert.Equal(t, 1, output.Skipped) + assert.Equal(t, 2, calls) +} diff --git a/pkg/agentic/commands.go b/pkg/agentic/commands.go index f83f111..db322c3 100644 --- a/pkg/agentic/commands.go +++ b/pkg/agentic/commands.go @@ -21,6 +21,7 @@ func (s *PrepSubsystem) registerCommands(ctx context.Context) { c.Command("run/orchestrator", core.Command{Description: "Run the queue orchestrator (standalone, no MCP)", Action: s.cmdOrchestrator}) c.Command("prep", core.Command{Description: "Prepare a workspace: clone repo, build prompt", Action: s.cmdPrep}) c.Command("generate", core.Command{Description: "Generate content from a prompt using the platform content pipeline", Action: s.cmdGenerate}) + c.Command("brain/seed-memory", core.Command{Description: "Import markdown memories into OpenBrain from a project memory directory", Action: s.cmdBrainSeedMemory}) c.Command("plan-cleanup", core.Command{Description: "Permanently delete archived plans past the retention period", Action: s.cmdPlanCleanup}) c.Command("status", core.Command{Description: "List agent workspace statuses", Action: s.cmdStatus}) c.Command("prompt", core.Command{Description: "Build and display an agent prompt for a repo", Action: s.cmdPrompt}) diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index 47458ef..0b1dbb8 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -580,6 +580,7 @@ func TestPrep_OnStartup_Good_RegistersGenerateCommand(t *testing.T) { require.True(t, s.OnStartup(context.Background()).OK) assert.Contains(t, c.Commands(), "generate") + assert.Contains(t, c.Commands(), "brain/seed-memory") assert.Contains(t, c.Commands(), "plan-cleanup") }