Skip to content

Commit 5ee1eb4

Browse files
committed
feat: add DAG branching, prefix lookup, and LangGraph import/export
- engine.Branch() creates child nodes from any existing node (DAG branching) - storage.FindByPrefix() for short-prefix node lookup (UX improvement) - ImportLangGraph/ExportForLangGraph for cross-tool conversation migration
1 parent 3099e88 commit 5ee1eb4

6 files changed

Lines changed: 609 additions & 0 deletions

File tree

engine/branch.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package engine
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"fmt"
7+
8+
"github.com/google/uuid"
9+
"github.com/GrayCodeAI/yaad/storage"
10+
)
11+
12+
// Branch creates a new node branching off from the given nodeID.
13+
// The new node inherits the parent's project and scope but carries the
14+
// provided newContent and newType. An edge of type "caused_by" links the
15+
// branch node back to its parent, forming a DAG branch.
16+
func (e *Engine) Branch(ctx context.Context, nodeID, newContent, newType string) (*storage.Node, error) {
17+
if err := ctx.Err(); err != nil {
18+
return nil, err
19+
}
20+
if newContent == "" {
21+
return nil, fmt.Errorf("branch content cannot be empty")
22+
}
23+
if newType != "" && !validNodeTypes[newType] {
24+
return nil, fmt.Errorf("invalid node type: %q", newType)
25+
}
26+
27+
// Fetch the parent node to inherit project/scope.
28+
parent, err := e.store.GetNode(ctx, nodeID)
29+
if err != nil {
30+
return nil, fmt.Errorf("branch: parent not found: %w", err)
31+
}
32+
33+
// Compute content hash for the new node.
34+
h := sha256.Sum256([]byte(newContent + "\x00" + parent.Scope + "\x00" + parent.Project))
35+
hash := fmt.Sprintf("%x", h)
36+
37+
node := &storage.Node{
38+
ID: uuid.New().String(),
39+
Type: newType,
40+
Content: newContent,
41+
ContentHash: hash,
42+
Scope: parent.Scope,
43+
Project: parent.Project,
44+
Tier: defaultTier(newType),
45+
Confidence: 1.0,
46+
Version: 1,
47+
}
48+
49+
e.mu.Lock()
50+
defer e.mu.Unlock()
51+
52+
if err := e.graph.AddNode(ctx, node); err != nil {
53+
return nil, fmt.Errorf("branch: create node failed: %w", err)
54+
}
55+
56+
// Create an edge from the branched node back to the parent (DAG link).
57+
edge := &storage.Edge{
58+
ID: uuid.New().String(),
59+
FromID: node.ID,
60+
ToID: nodeID,
61+
Type: "caused_by",
62+
Weight: 1.0,
63+
}
64+
if err := e.graph.AddEdge(ctx, edge); err != nil {
65+
return nil, fmt.Errorf("branch: edge failed: %w", err)
66+
}
67+
68+
return node, nil
69+
}

engine/branch_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package engine
2+
3+
import (
4+
"context"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/GrayCodeAI/yaad/graph"
9+
"github.com/GrayCodeAI/yaad/storage"
10+
)
11+
12+
func TestBranch(t *testing.T) {
13+
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))
14+
if err != nil {
15+
t.Fatal(err)
16+
}
17+
defer store.Close()
18+
19+
g := graph.New(store, store.DB())
20+
eng := New(store, g)
21+
defer eng.Close()
22+
ctx := context.Background()
23+
24+
// Create a parent node via Remember
25+
parent, err := eng.Remember(ctx, RememberInput{
26+
Type: "decision",
27+
Content: "We chose PostgreSQL for the database layer",
28+
Scope: "project",
29+
Project: "myproject",
30+
})
31+
if err != nil {
32+
t.Fatalf("Remember parent: %v", err)
33+
}
34+
35+
// Branch off a new node
36+
branch, err := eng.Branch(ctx, parent.ID, "Actually, switching to SQLite for dev", "decision")
37+
if err != nil {
38+
t.Fatalf("Branch: %v", err)
39+
}
40+
41+
// Verify the branch node exists and inherits project/scope
42+
if branch.Project != parent.Project {
43+
t.Errorf("project mismatch: got %q, want %q", branch.Project, parent.Project)
44+
}
45+
if branch.Scope != parent.Scope {
46+
t.Errorf("scope mismatch: got %q, want %q", branch.Scope, parent.Scope)
47+
}
48+
if branch.Content != "Actually, switching to SQLite for dev" {
49+
t.Errorf("content mismatch: got %q", branch.Content)
50+
}
51+
if branch.Type != "decision" {
52+
t.Errorf("type mismatch: got %q, want %q", branch.Type, "decision")
53+
}
54+
55+
// Verify edge from branch → parent exists
56+
edges, err := store.GetEdgesFrom(ctx, branch.ID)
57+
if err != nil {
58+
t.Fatalf("GetEdgesFrom: %v", err)
59+
}
60+
found := false
61+
for _, e := range edges {
62+
if e.ToID == parent.ID && e.Type == "caused_by" {
63+
found = true
64+
break
65+
}
66+
}
67+
if !found {
68+
t.Error("expected edge from branch to parent not found")
69+
}
70+
}
71+
72+
func TestBranch_EmptyContent(t *testing.T) {
73+
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))
74+
if err != nil {
75+
t.Fatal(err)
76+
}
77+
defer store.Close()
78+
79+
g := graph.New(store, store.DB())
80+
eng := New(store, g)
81+
defer eng.Close()
82+
ctx := context.Background()
83+
84+
parent, err := eng.Remember(ctx, RememberInput{
85+
Type: "convention",
86+
Content: "Use gofmt",
87+
Scope: "project",
88+
Project: "proj",
89+
})
90+
if err != nil {
91+
t.Fatal(err)
92+
}
93+
94+
_, err = eng.Branch(ctx, parent.ID, "", "convention")
95+
if err == nil {
96+
t.Fatal("expected error for empty content")
97+
}
98+
}
99+
100+
func TestBranch_ParentNotFound(t *testing.T) {
101+
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))
102+
if err != nil {
103+
t.Fatal(err)
104+
}
105+
defer store.Close()
106+
107+
g := graph.New(store, store.DB())
108+
eng := New(store, g)
109+
defer eng.Close()
110+
ctx := context.Background()
111+
112+
_, err = eng.Branch(ctx, "nonexistent-id", "some content", "decision")
113+
if err == nil {
114+
t.Fatal("expected error for nonexistent parent")
115+
}
116+
}

exportimport/langgraph.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package exportimport
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"encoding/json"
7+
"fmt"
8+
"time"
9+
10+
"github.com/GrayCodeAI/yaad/storage"
11+
)
12+
13+
// LangGraphEntry represents a single entry in the LangGraph-compatible JSON format.
14+
type LangGraphEntry struct {
15+
ID string `json:"id"`
16+
ParentID string `json:"parent_id,omitempty"`
17+
Role string `json:"role"`
18+
Content string `json:"content"`
19+
Metadata map[string]interface{} `json:"metadata,omitempty"`
20+
}
21+
22+
// ImportLangGraph imports LangGraph-format JSON into yaad storage.
23+
// Each entry is mapped to a yaad Node with Type="entity".
24+
// Returns the count of successfully imported nodes.
25+
func ImportLangGraph(ctx context.Context, store storage.Storage, jsonData []byte) (int, error) {
26+
var entries []LangGraphEntry
27+
if err := json.Unmarshal(jsonData, &entries); err != nil {
28+
return 0, fmt.Errorf("parsing langgraph JSON: %w", err)
29+
}
30+
31+
imported := 0
32+
for _, entry := range entries {
33+
if entry.ID == "" || entry.Content == "" {
34+
continue
35+
}
36+
37+
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(entry.ID+entry.Content)))
38+
node := &storage.Node{
39+
ID: entry.ID,
40+
Type: "entity",
41+
Content: entry.Content,
42+
ContentHash: hash,
43+
Confidence: 1.0,
44+
Version: 1,
45+
CreatedAt: time.Now(),
46+
UpdatedAt: time.Now(),
47+
}
48+
49+
// Map role to tags
50+
if entry.Role != "" {
51+
node.Tags = "role:" + entry.Role
52+
}
53+
54+
// Store metadata as summary if present
55+
if entry.Metadata != nil {
56+
if metaBytes, err := json.Marshal(entry.Metadata); err == nil {
57+
node.Summary = string(metaBytes)
58+
}
59+
}
60+
61+
if err := store.CreateNode(ctx, node); err != nil {
62+
continue // skip duplicates
63+
}
64+
imported++
65+
66+
// Create edge to parent if parent_id is specified
67+
if entry.ParentID != "" {
68+
edge := &storage.Edge{
69+
ID: fmt.Sprintf("%s->%s", entry.ParentID, entry.ID),
70+
FromID: entry.ParentID,
71+
ToID: entry.ID,
72+
Type: "parent",
73+
CreatedAt: time.Now(),
74+
}
75+
store.CreateEdge(ctx, edge) // best-effort
76+
}
77+
}
78+
79+
return imported, nil
80+
}
81+
82+
// ExportForLangGraph exports yaad nodes for a project as LangGraph-compatible JSON.
83+
func ExportForLangGraph(ctx context.Context, store storage.Storage, project string) ([]byte, error) {
84+
nodes, err := store.ListNodes(ctx, storage.NodeFilter{Project: project})
85+
if err != nil {
86+
return nil, fmt.Errorf("listing nodes: %w", err)
87+
}
88+
89+
entries := make([]LangGraphEntry, 0, len(nodes))
90+
for _, n := range nodes {
91+
entry := LangGraphEntry{
92+
ID: n.ID,
93+
Role: extractRole(n.Tags),
94+
Content: n.Content,
95+
}
96+
97+
// Reconstruct metadata from summary
98+
if n.Summary != "" {
99+
var meta map[string]interface{}
100+
if err := json.Unmarshal([]byte(n.Summary), &meta); err == nil {
101+
entry.Metadata = meta
102+
}
103+
}
104+
105+
// Look up parent edge
106+
edges, _ := store.GetEdgesTo(ctx, n.ID)
107+
for _, e := range edges {
108+
if e.Type == "parent" {
109+
entry.ParentID = e.FromID
110+
break
111+
}
112+
}
113+
114+
entries = append(entries, entry)
115+
}
116+
117+
return json.MarshalIndent(entries, "", " ")
118+
}
119+
120+
// extractRole extracts the role from a tags string like "role:assistant".
121+
func extractRole(tags string) string {
122+
if len(tags) > 5 && tags[:5] == "role:" {
123+
return tags[5:]
124+
}
125+
return ""
126+
}

0 commit comments

Comments
 (0)