Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions cmd/bud/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,25 @@ func main() {
}
return graphDB.MarkTraceDone(traceShortID, resolutionEpisodeShortID)
},
GetTraceInfo: func(traceShortID string) (*tools.LocalTraceInfo, error) {
if graphDB == nil {
return nil, fmt.Errorf("graph DB not available")
}
trace, err := graphDB.GetTraceByShortID(traceShortID)
if err != nil || trace == nil {
return nil, err
}
summaries, err := graphDB.GetTraceSummariesAll(trace.ID)
if err != nil {
summaries = nil
}
return &tools.LocalTraceInfo{
Done: trace.Done,
Resolution: trace.Resolution,
DoneAt: trace.DoneAt,
PyramidSummaries: summaries,
}, nil
},
OnMCPToolCall: func(toolName string) {
if exec != nil {
exec.GetMCPToolCallback()(toolName)
Expand Down
98 changes: 94 additions & 4 deletions internal/consolidate/consolidate.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,24 @@ func (c *Consolidator) Run() (int, error) {
c.printEdgeSummaries(episodes, episodeEdges)
}

// Store edges in database (only if both episodes exist)
// Separate contradiction edges from regular edges before clustering.
// Contradiction edges are stored to the DB for audit purposes but excluded from the
// clustering adjacency list so contradicting episodes form separate traces rather
// than being merged into a single trace (which would silently discard the conflict).
var contradictionEdges []EpisodeEdge
var regularEdges []EpisodeEdge
for _, edge := range episodeEdges {
if edge.Relationship == "contradicts" {
contradictionEdges = append(contradictionEdges, edge)
} else {
regularEdges = append(regularEdges, edge)
}
}
if len(contradictionEdges) > 0 {
log.Printf("[consolidate] Segregating %d contradiction edges from clustering", len(contradictionEdges))
}

// Store ALL edges in database (including contradictions, for audit/debugging)
episodeIDs := make(map[string]bool)
for _, ep := range episodes {
episodeIDs[ep.ID] = true
Expand All @@ -146,9 +163,9 @@ func (c *Consolidator) Run() (int, error) {
}
}

// Phase 2: Graph clustering using Claude-inferred edges
// Returns: new groups (to be consolidated) and existing traces with new episodes
newGroups, existingTracesWithNewEpisodes := c.clusterEpisodesByEdges(episodes, episodeEdges)
// Phase 2: Graph clustering using only regular (non-contradiction) edges.
// Contradiction edges are excluded so contradicting episodes land in separate traces.
newGroups, existingTracesWithNewEpisodes := c.clusterEpisodesByEdges(episodes, regularEdges)

// Phase 3a: Add new episodes to existing traces and mark for reconsolidation
for traceID, newEpisodes := range existingTracesWithNewEpisodes {
Expand Down Expand Up @@ -184,6 +201,17 @@ func (c *Consolidator) Run() (int, error) {
log.Printf("[consolidate] Created %d episode→trace cross-reference edges", linked)
}

// Phase 3d: Mark conflicting traces from contradiction edges.
// Now that episodes have been assigned to traces, we can identify which traces
// contain contradicting information and flag them for executive review.
if len(contradictionEdges) > 0 {
conflictPairs := c.markContradictionConflicts(contradictionEdges)
if conflictPairs > 0 {
log.Printf("[consolidate] Marked %d conflicting trace pairs from %d contradiction edges",
conflictPairs, len(contradictionEdges))
}
}

// Phase 4: Batch reconsolidation of traces with new episodes
tracesNeedingRecon, err := c.graph.GetTracesNeedingReconsolidation()
if err != nil {
Expand Down Expand Up @@ -318,6 +346,68 @@ func (c *Consolidator) clusterEpisodesByEdges(episodes []*graph.Episode, edges [
return newGroups, existingTracesWithNewEpisodes
}

// markContradictionConflicts identifies trace pairs connected by "contradicts" edges and
// marks both traces with has_conflict=true and the other's short_id in conflict_with.
// Returns the number of distinct trace pairs marked.
func (c *Consolidator) markContradictionConflicts(contradictionEdges []EpisodeEdge) int {
type tracePair struct{ a, b string }
seen := make(map[tracePair]bool)
marked := 0

for _, edge := range contradictionEdges {
// Find which trace each episode belongs to
tracesA, err := c.graph.GetEpisodeTraces(edge.FromID)
if err != nil || len(tracesA) == 0 {
continue
}
tracesB, err := c.graph.GetEpisodeTraces(edge.ToID)
if err != nil || len(tracesB) == 0 {
continue
}

traceA := tracesA[0]
traceB := tracesB[0]

// Skip if same trace or ephemeral
if traceA == traceB || traceA == "_ephemeral" || traceB == "_ephemeral" {
continue
}

// Skip duplicate pairs
pair := tracePair{traceA, traceB}
if traceA > traceB {
pair = tracePair{traceB, traceA}
}
if seen[pair] {
continue
}
seen[pair] = true

// Get short IDs for human-readable conflict_with field
shortA, err := c.graph.GetTraceShortID(traceA)
if err != nil || shortA == "" {
continue
}
shortB, err := c.graph.GetTraceShortID(traceB)
if err != nil || shortB == "" {
continue
}

// Mark both traces as conflicting with each other
if err := c.graph.MarkTraceConflict(traceA, shortB); err != nil {
log.Printf("[consolidate] Failed to mark conflict on trace %s: %v", shortA, err)
continue
}
if err := c.graph.MarkTraceConflict(traceB, shortA); err != nil {
log.Printf("[consolidate] Failed to mark conflict on trace %s: %v", shortB, err)
continue
}
marked++
}

return marked
}

// reconsolidateTrace regenerates a trace's summary and metadata after new episodes are added
func (c *Consolidator) reconsolidateTrace(traceID string) error {
// Get all source episodes for this trace
Expand Down
15 changes: 10 additions & 5 deletions internal/engram/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type Entity struct {
// JSON field names match Engram's "engram" type.
type Trace struct {
ID string `json:"id"`
ShortID string `json:"short_id,omitempty"`
Summary string `json:"summary"`
Topic string `json:"topic,omitempty"`
TraceType string `json:"engram_type,omitempty"`
Expand All @@ -79,6 +80,9 @@ type Trace struct {
LabileUntil time.Time `json:"labile_until,omitempty"`
SourceIDs []string `json:"source_ids,omitempty"`
EntityIDs []string `json:"entity_ids,omitempty"`
// Conflict tracking (populated when contradicting traces are detected during consolidation)
HasConflict bool `json:"has_conflict,omitempty"`
ConflictWith string `json:"conflict_with,omitempty"` // CSV of conflicting trace short_ids
}

// TraceContext holds a trace with its source episodes and linked entities.
Expand Down Expand Up @@ -177,14 +181,15 @@ func (c *Client) Consolidate() (*ConsolidateResult, error) {
// limit <= 0 uses the server default (10).
// Returns a RetrievalResult with Traces populated; Episodes and Entities are empty.
func (c *Client) Search(query string, limit int) (*RetrievalResult, error) {
params := url.Values{}
params.Set("query", query)
params.Set("detail", "full")
body := map[string]any{
"query": query,
"detail": "full",
}
if limit > 0 {
params.Set("limit", strconv.Itoa(limit))
body["limit"] = limit
}
var traces []*Trace
if err := c.get("/v1/engrams", params, &traces); err != nil {
if err := c.post("/v1/engrams/search", body, &traces); err != nil {
return nil, err
}
return &RetrievalResult{Traces: traces}, nil
Expand Down
15 changes: 11 additions & 4 deletions internal/engram/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,20 @@ func TestIngestThought(t *testing.T) {

func TestSearch(t *testing.T) {
c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/engrams" {
if r.URL.Path != "/v1/engrams/search" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("query") != "test query" {
t.Errorf("expected query param 'test query', got %q", r.URL.Query().Get("query"))
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
if body["query"] != "test query" {
t.Errorf("expected query 'test query', got %q", body["query"])
}
if r.URL.Query().Get("detail") != "full" {
if body["detail"] != "full" {
t.Errorf("expected detail=full")
}
traces := []*Trace{{ID: "tr-1", Summary: "a trace"}}
Expand Down
141 changes: 141 additions & 0 deletions internal/executive/buildprompt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package executive

import (
"strings"
"testing"
"time"

"github.com/vthunder/bud2/internal/focus"
)

// newTestExecutive creates a minimal ExecutiveV2 for prompt-building tests.
// memory and reflexLog are nil; statePath is a temp dir.
func newTestExecutive(t *testing.T) *ExecutiveV2 {
t.Helper()
statePath := t.TempDir()
return NewExecutiveV2(nil, nil, statePath, ExecutiveV2Config{})
}

// TestBuildPrompt_ConflictFormatting verifies that conflicting trace pairs are
// grouped together with a "[CONFLICT]" label and the "contradicts above" annotation.
func TestBuildPrompt_ConflictFormatting(t *testing.T) {
exec := newTestExecutive(t)

ts := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)
ts2 := time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC)

// Trace A has short_id "abc12", says "prefers dark mode", and conflicts with "def34"
// Trace B has short_id "def34", says "switched to light mode", and conflicts with "abc12"
bundle := &focus.ContextBundle{
Memories: []focus.MemorySummary{
{
ID: "trace-a-full-id",
ShortID: "abc12",
Summary: "User prefers dark mode",
Relevance: 0.9,
Timestamp: ts,
HasConflict: true,
ConflictWith: "def34",
},
{
ID: "trace-b-full-id",
ShortID: "def34",
Summary: "User switched to light mode",
Relevance: 0.8,
Timestamp: ts2,
HasConflict: true,
ConflictWith: "abc12",
},
},
}

out := exec.buildPrompt(bundle)

// Should contain CONFLICT label
if !strings.Contains(out, "[CONFLICT]") {
t.Errorf("expected [CONFLICT] label in output, got:\n%s", out)
}
// Should contain both summaries
if !strings.Contains(out, "User prefers dark mode") {
t.Errorf("expected trace A summary in output, got:\n%s", out)
}
if !strings.Contains(out, "User switched to light mode") {
t.Errorf("expected trace B summary in output, got:\n%s", out)
}
// Should contain "contradicts above" annotation
if !strings.Contains(out, "contradicts above") {
t.Errorf("expected 'contradicts above' annotation, got:\n%s", out)
}
// Should NOT format either trace as a plain memory line (no double-listing)
// Count occurrences of the summaries - each should appear exactly once
countA := strings.Count(out, "User prefers dark mode")
countB := strings.Count(out, "User switched to light mode")
if countA != 1 {
t.Errorf("trace A summary should appear exactly once, got %d times", countA)
}
if countB != 1 {
t.Errorf("trace B summary should appear exactly once, got %d times", countB)
}
}

// TestBuildPrompt_NonConflictFormatting verifies that normal (non-conflicted) memories
// are still formatted in the standard "[displayID] [timeStr] summary" style.
func TestBuildPrompt_NonConflictFormatting(t *testing.T) {
exec := newTestExecutive(t)

ts := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)

bundle := &focus.ContextBundle{
Memories: []focus.MemorySummary{
{
ID: "trace-normal-id",
ShortID: "aa111",
Summary: "User prefers vim keybindings",
Relevance: 0.7,
Timestamp: ts,
},
},
}

out := exec.buildPrompt(bundle)

if strings.Contains(out, "[CONFLICT]") {
t.Errorf("unexpected [CONFLICT] label for non-conflicted memory, got:\n%s", out)
}
if !strings.Contains(out, "User prefers vim keybindings") {
t.Errorf("expected summary in output, got:\n%s", out)
}
}

// TestBuildPrompt_ConflictWithMissingPartner verifies that a conflicted trace whose
// partner is NOT in the retrieved set still renders as a normal (non-paired) memory.
func TestBuildPrompt_ConflictWithMissingPartner(t *testing.T) {
exec := newTestExecutive(t)

ts := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)

bundle := &focus.ContextBundle{
Memories: []focus.MemorySummary{
{
ID: "trace-orphan-id",
ShortID: "xxx99",
Summary: "User prefers dark mode",
Relevance: 0.9,
Timestamp: ts,
HasConflict: true,
ConflictWith: "yyy00", // partner not in result set
},
},
}

out := exec.buildPrompt(bundle)

// No CONFLICT label since partner is absent
if strings.Contains(out, "[CONFLICT]") {
t.Errorf("unexpected [CONFLICT] label when partner is absent, got:\n%s", out)
}
// Still shows the memory
if !strings.Contains(out, "User prefers dark mode") {
t.Errorf("expected orphan conflict summary in output, got:\n%s", out)
}
}
Loading