feat(agentic): add brain seed-memory command
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
3d528e6963
commit
155230fb5b
4 changed files with 438 additions and 0 deletions
309
pkg/agentic/brain_seed_memory.go
Normal file
309
pkg/agentic/brain_seed_memory.go
Normal file
|
|
@ -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 ""
|
||||
}
|
||||
127
pkg/agentic/brain_seed_memory_test.go
Normal file
127
pkg/agentic/brain_seed_memory_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue