From 5b93d38a0f92c817d481ce684bc46a1e0798d987 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Sun, 19 Apr 2026 23:55:57 -0400 Subject: [PATCH 01/32] Create Memory-graph.go --- cmd/Memory-graph.go | 631 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 631 insertions(+) create mode 100644 cmd/Memory-graph.go diff --git a/cmd/Memory-graph.go b/cmd/Memory-graph.go new file mode 100644 index 0000000..351b79a --- /dev/null +++ b/cmd/Memory-graph.go @@ -0,0 +1,631 @@ +// Package memorygraph implements a persistent memory graph for interlinked RAG. +// Nodes represent typed knowledge units; edges are weighted, typed relations. +// The graph is stored as a single JSON file under rootDir/.supermodel/memory-graph.json +// and is safe for concurrent reads within a process (writes hold a mutex). +package memorygraph + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// --- Types ------------------------------------------------------------------- + +// NodeType classifies what kind of knowledge a node represents. +type NodeType string + +const ( + NodeTypeFact NodeType = "fact" + NodeTypeConcept NodeType = "concept" + NodeTypeEntity NodeType = "entity" + NodeTypeEvent NodeType = "event" + NodeTypeProcedure NodeType = "procedure" + NodeTypeContext NodeType = "context" +) + +// RelationType classifies the semantic relationship between two nodes. +type RelationType string + +const ( + RelationRelatedTo RelationType = "related_to" + RelationDependsOn RelationType = "depends_on" + RelationPartOf RelationType = "part_of" + RelationLeadsTo RelationType = "leads_to" + RelationContrasts RelationType = "contrasts" + RelationSimilarTo RelationType = "similar_to" + RelationInstantiates RelationType = "instantiates" +) + +// Node is a single knowledge unit in the memory graph. +type Node struct { + ID string `json:"id"` + Type NodeType `json:"type"` + Label string `json:"label"` + Content string `json:"content"` + Metadata map[string]string `json:"metadata,omitempty"` + AccessCount int `json:"accessCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Edge is a directed, weighted relation between two nodes. +type Edge struct { + ID string `json:"id"` + Source string `json:"source"` + Target string `json:"target"` + Relation RelationType `json:"relation"` + Weight float64 `json:"weight"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// TraversalResult is a node reached during graph traversal, enriched with +// path context and a relevance score. +type TraversalResult struct { + Node Node + Depth int + RelevanceScore float64 + PathRelations []string // relation labels along the path from the start node +} + +// GraphStats summarises the current state of the graph. +type GraphStats struct { + Nodes int + Edges int +} + +// graphData is the on-disk format. +type graphData struct { + Nodes []Node `json:"nodes"` + Edges []Edge `json:"edges"` +} + +// --- Storage ----------------------------------------------------------------- + +const graphFile = ".supermodel/memory-graph.json" + +var ( + mu sync.RWMutex + cache = map[string]*graphData{} // rootDir → loaded graph +) + +func graphPath(rootDir string) string { + return filepath.Join(rootDir, graphFile) +} + +// load reads the graph for rootDir from disk (or returns the in-memory cache). +func load(rootDir string) (*graphData, error) { + mu.RLock() + if g, ok := cache[rootDir]; ok { + mu.RUnlock() + return g, nil + } + mu.RUnlock() + + mu.Lock() + defer mu.Unlock() + + // Double-checked locking. + if g, ok := cache[rootDir]; ok { + return g, nil + } + + path := graphPath(rootDir) + g := &graphData{} + + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("memorygraph: read %s: %w", path, err) + } + if err == nil { + if err := json.Unmarshal(data, g); err != nil { + return nil, fmt.Errorf("memorygraph: parse %s: %w", path, err) + } + } + + cache[rootDir] = g + return g, nil +} + +// save persists g to disk. Caller must hold mu (write lock). +func save(rootDir string, g *graphData) error { + path := graphPath(rootDir) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("memorygraph: mkdir: %w", err) + } + data, err := json.MarshalIndent(g, "", " ") + if err != nil { + return fmt.Errorf("memorygraph: marshal: %w", err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("memorygraph: write: %w", err) + } + return nil +} + +// nodeID derives a stable deterministic ID from type+label. +func nodeID(t NodeType, label string) string { + return fmt.Sprintf("%s:%s", t, strings.ToLower(strings.ReplaceAll(label, " ", "_"))) +} + +// edgeID derives a stable deterministic ID from endpoints+relation. +func edgeID(source, target string, relation RelationType) string { + return fmt.Sprintf("%s--%s-->%s", source, relation, target) +} + +// --- Core operations --------------------------------------------------------- + +// UpsertNode creates or updates a node identified by (type, label). +// If the node already exists its content and metadata are updated in-place. +func UpsertNode(rootDir string, t NodeType, label, content string, metadata map[string]string) (*Node, error) { + mu.Lock() + defer mu.Unlock() + + g, err := loadLocked(rootDir) + if err != nil { + return nil, err + } + + id := nodeID(t, label) + now := time.Now().UTC() + + for i := range g.Nodes { + if g.Nodes[i].ID == id { + g.Nodes[i].Content = content + g.Nodes[i].Metadata = metadata + g.Nodes[i].UpdatedAt = now + g.Nodes[i].AccessCount++ + node := g.Nodes[i] + if err := save(rootDir, g); err != nil { + return nil, err + } + return &node, nil + } + } + + node := Node{ + ID: id, + Type: t, + Label: label, + Content: content, + Metadata: metadata, + CreatedAt: now, + UpdatedAt: now, + } + g.Nodes = append(g.Nodes, node) + if err := save(rootDir, g); err != nil { + return nil, err + } + return &node, nil +} + +// CreateRelation adds a directed edge between two existing nodes. +// Returns nil if either node ID is not found. +func CreateRelation(rootDir, sourceID, targetID string, relation RelationType, weight float64, metadata map[string]string) (*Edge, error) { + mu.Lock() + defer mu.Unlock() + + g, err := loadLocked(rootDir) + if err != nil { + return nil, err + } + + if !nodeExists(g, sourceID) || !nodeExists(g, targetID) { + return nil, nil //nolint:nilnil // caller checks nil to detect missing nodes + } + + if weight <= 0 { + weight = 1.0 + } + + id := edgeID(sourceID, targetID, relation) + + // Upsert: update weight if edge already exists. + for i := range g.Edges { + if g.Edges[i].ID == id { + g.Edges[i].Weight = weight + g.Edges[i].Metadata = metadata + edge := g.Edges[i] + if err := save(rootDir, g); err != nil { + return nil, err + } + return &edge, nil + } + } + + edge := Edge{ + ID: id, + Source: sourceID, + Target: targetID, + Relation: relation, + Weight: weight, + Metadata: metadata, + } + g.Edges = append(g.Edges, edge) + if err := save(rootDir, g); err != nil { + return nil, err + } + return &edge, nil +} + +// GetGraphStats returns a snapshot of node and edge counts. +func GetGraphStats(rootDir string) (GraphStats, error) { + g, err := load(rootDir) + if err != nil { + return GraphStats{}, err + } + return GraphStats{Nodes: len(g.Nodes), Edges: len(g.Edges)}, nil +} + +// PruneResult reports what was removed during a prune pass. +type PruneResult struct { + Removed int + Remaining int +} + +// PruneStaleLinks removes edges whose weight falls below threshold and then +// removes any nodes that have become fully orphaned (no edges in or out). +func PruneStaleLinks(rootDir string, threshold float64) (PruneResult, error) { + if threshold <= 0 { + threshold = 0.1 + } + + mu.Lock() + defer mu.Unlock() + + g, err := loadLocked(rootDir) + if err != nil { + return PruneResult{}, err + } + + removed := 0 + + // Remove weak edges. + live := g.Edges[:0] + for _, e := range g.Edges { + if e.Weight >= threshold { + live = append(live, e) + } else { + removed++ + } + } + g.Edges = live + + // Remove orphaned nodes (no remaining edges reference them). + connected := make(map[string]bool, len(g.Nodes)) + for _, e := range g.Edges { + connected[e.Source] = true + connected[e.Target] = true + } + liveNodes := g.Nodes[:0] + for _, n := range g.Nodes { + if connected[n.ID] { + liveNodes = append(liveNodes, n) + } else { + removed++ + } + } + g.Nodes = liveNodes + +< truncated lines 316-378 > +} + +// SearchGraph finds nodes whose label or content matches query, then expands +// one hop to collect linked neighbors. Results are scored by relevance. +func SearchGraph(rootDir, query string, maxDepth, topK int, edgeFilter []RelationType) (*SearchResult, error) { + if maxDepth <= 0 { + maxDepth = 2 + } + if topK <= 0 { + topK = 10 + } + + g, err := load(rootDir) + if err != nil { + return nil, err + } + + queryLower := strings.ToLower(query) + nodeByID := indexNodes(g) + adjOut := buildAdjacency(g, edgeFilter) // nodeID → []Edge + + // Score all nodes against the query. + type scored struct { + node Node + score float64 + } + var candidates []scored + for _, n := range g.Nodes { + score := scoreNode(n, queryLower) + if score > 0 { + candidates = append(candidates, scored{node: n, score: score}) + } + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].score > candidates[j].score + }) + if len(candidates) > topK { + candidates = candidates[:topK] + } + + result := &SearchResult{ + TotalNodes: len(g.Nodes), + TotalEdges: len(g.Edges), + } + directIDs := make(map[string]bool) + for _, c := range candidates { + result.Direct = append(result.Direct, TraversalResult{ + Node: c.node, + Depth: 0, + RelevanceScore: c.score, + PathRelations: []string{}, + }) + directIDs[c.node.ID] = true + } + + // Expand one hop of neighbors. + neighborIDs := make(map[string]bool) + for _, c := range candidates { + for _, edge := range adjOut[c.node.ID] { + if directIDs[edge.Target] || neighborIDs[edge.Target] { + continue + } + if n, ok := nodeByID[edge.Target]; ok { + neighborIDs[edge.Target] = true + score := scoreNode(n, queryLower) * edge.Weight * 0.5 + result.Neighbors = append(result.Neighbors, TraversalResult{ + Node: n, + Depth: 1, + RelevanceScore: score, + PathRelations: []string{string(edge.Relation)}, + }) + } + } + } + sort.Slice(result.Neighbors, func(i, j int) bool { + return result.Neighbors[i].RelevanceScore > result.Neighbors[j].RelevanceScore + }) + + // Bump access counts for returned nodes. + go func() { _ = bumpAccess(rootDir, directIDs) }() + + return result, nil +} + +// RetrieveWithTraversal performs a BFS/depth-limited walk starting from +// startNodeID, returning all reachable nodes up to maxDepth hops away. +// edgeFilter restricts which relation types are followed; nil follows all. +func RetrieveWithTraversal(rootDir, startNodeID string, maxDepth int, edgeFilter []RelationType) ([]TraversalResult, error) { + if maxDepth <= 0 { + maxDepth = 2 + } + + g, err := load(rootDir) + if err != nil { + return nil, err + } + + nodeByID := indexNodes(g) + startNode, ok := nodeByID[startNodeID] + if !ok { + return nil, nil + } + + adjOut := buildAdjacency(g, edgeFilter) + + type queueItem struct { + nodeID string + depth int + pathRelations []string + score float64 + } + + visited := map[string]bool{startNodeID: true} + queue := []queueItem{{nodeID: startNodeID, depth: 0, pathRelations: []string{}, score: 1.0}} + var results []TraversalResult + + results = append(results, TraversalResult{ + Node: startNode, + Depth: 0, + RelevanceScore: 1.0, + PathRelations: []string{}, + }) + + for len(queue) > 0 { + item := queue[0] + queue = queue[1:] + + if item.depth >= maxDepth { + continue + } + + for _, edge := range adjOut[item.nodeID] { + if visited[edge.Target] { + continue + } + visited[edge.Target] = true + + n, ok := nodeByID[edge.Target] + if !ok { + continue + } + + // Decay relevance with depth and edge weight. + score := item.score * edge.Weight * math.Pow(0.8, float64(item.depth+1)) + pathRels := append(append([]string(nil), item.pathRelations...), string(edge.Relation)) + + results = append(results, TraversalResult{ + Node: n, + Depth: item.depth + 1, + RelevanceScore: score, + PathRelations: pathRels, + }) + queue = append(queue, queueItem{ + nodeID: edge.Target, + depth: item.depth + 1, + pathRelations: pathRels, + score: score, + }) + } + } + + // Sort by depth first, then descending relevance. + sort.Slice(results, func(i, j int) bool { + if results[i].Depth != results[j].Depth { + return results[i].Depth < results[j].Depth + } + return results[i].RelevanceScore > results[j].RelevanceScore + }) + + go func() { + ids := make(map[string]bool, len(results)) + for _, r := range results { + ids[r.Node.ID] = true + } + _ = bumpAccess(rootDir, ids) + }() + + return results, nil +} + +// --- Internal helpers -------------------------------------------------------- + +// loadLocked loads the graph assuming the caller already holds mu (write lock). +// It reads directly from the in-memory cache or from disk without acquiring +// any additional locks (caller already holds the write lock). +func loadLocked(rootDir string) (*graphData, error) { + if g, ok := cache[rootDir]; ok { + return g, nil + } + path := graphPath(rootDir) + g := &graphData{} + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("memorygraph: read %s: %w", path, err) + } + if err == nil { + if err := json.Unmarshal(data, g); err != nil { + return nil, fmt.Errorf("memorygraph: parse %s: %w", path, err) + } + } + cache[rootDir] = g + return g, nil +} + +func nodeExists(g *graphData, id string) bool { + for _, n := range g.Nodes { + if n.ID == id { + return true + } + } + return false +} + +func indexNodes(g *graphData) map[string]Node { + m := make(map[string]Node, len(g.Nodes)) + for _, n := range g.Nodes { + m[n.ID] = n + } + return m +} + +// buildAdjacency returns a map of nodeID → outgoing edges, optionally filtered +// by relation type. +func buildAdjacency(g *graphData, filter []RelationType) map[string][]Edge { + allowed := make(map[RelationType]bool, len(filter)) + for _, r := range filter { + allowed[r] = true + } + + adj := make(map[string][]Edge) + for _, e := range g.Edges { + if len(filter) > 0 && !allowed[e.Relation] { + continue + } + adj[e.Source] = append(adj[e.Source], e) + } + return adj +} + +// scoreNode scores a node against a lower-cased query string. +// Label matches are weighted more heavily than content matches. +func scoreNode(n Node, queryLower string) float64 { + labelLower := strings.ToLower(n.Label) + contentLower := strings.ToLower(n.Content) + + var score float64 + if strings.Contains(labelLower, queryLower) { + score += 1.0 + } + if strings.Contains(contentLower, queryLower) { + // Partial overlap: proportion of query tokens found in content. + score += tokenOverlap(queryLower, contentLower) * 0.6 + } + // Popularity bias: nodes accessed frequently are slightly preferred. + if n.AccessCount > 0 { + score += math.Log1p(float64(n.AccessCount)) * 0.05 + } + return score +} + +// tokenOverlap returns the fraction of query tokens present in text. +func tokenOverlap(query, text string) float64 { + queryTokens := strings.Fields(query) + if len(queryTokens) == 0 { + return 0 + } + found := 0 + for _, t := range queryTokens { + if strings.Contains(text, t) { + found++ + } + } + return float64(found) / float64(len(queryTokens)) +} + +// jaccardSimilarity approximates cosine similarity via token-set Jaccard. +func jaccardSimilarity(a, b string) float64 { + ta := tokenSet(a) + tb := tokenSet(b) + if len(ta) == 0 || len(tb) == 0 { + return 0 + } + intersection := 0 + for t := range ta { + if tb[t] { + intersection++ + } + } + return float64(intersection) / float64(len(ta)+len(tb)-intersection) +} + +func tokenSet(s string) map[string]bool { + m := make(map[string]bool) + for _, t := range strings.Fields(strings.ToLower(s)) { + m[t] = true + } + return m +} + +// bumpAccess increments the AccessCount for each node in ids and persists. +func bumpAccess(rootDir string, ids map[string]bool) error { + mu.Lock() + defer mu.Unlock() + + g, ok := cache[rootDir] + if !ok { + return nil + } + for i := range g.Nodes { + if ids[g.Nodes[i].ID] { + g.Nodes[i].AccessCount++ + } + } + return save(rootDir, g) +} From 7a0cf307af79a36f2bb6337f536e9aaedef053a9 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 00:03:30 -0400 Subject: [PATCH 02/32] Create Peek.go --- cmd/Peek.go | 231 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 cmd/Peek.go diff --git a/cmd/Peek.go b/cmd/Peek.go new file mode 100644 index 0000000..5bd12af --- /dev/null +++ b/cmd/Peek.go @@ -0,0 +1,231 @@ +package memorygraph + +import ( + "fmt" + "strings" + "time" +) + +// NodePeek is a full snapshot of a node and all its edges, returned by Peek. +type NodePeek struct { + Node Node + EdgesOut []EdgePeek // edges where this node is the source + EdgesIn []EdgePeek // edges where this node is the target +} + +// EdgePeek is a human-readable summary of a single edge and its peer node. +type EdgePeek struct { + Edge Edge + PeerID string + PeerLabel string + PeerType NodeType +} + +// PeekOptions controls what Peek returns. +type PeekOptions struct { + RootDir string + // NodeID takes priority if set. + NodeID string + // Label is used for lookup when NodeID is empty (first match wins). + Label string +} + +// Peek returns a full NodePeek for the requested node: its content, metadata, +// access stats, and every inbound/outbound edge with peer labels resolved. +// Returns nil if the node cannot be found. +func Peek(opts PeekOptions) (*NodePeek, error) { + g, err := load(opts.RootDir) + if err != nil { + return nil, err + } + + nodeByID := indexNodes(g) + + // Resolve target node. + var target *Node + if opts.NodeID != "" { + if n, ok := nodeByID[opts.NodeID]; ok { + target = &n + } + } else if opts.Label != "" { + labelLower := strings.ToLower(opts.Label) + for i := range g.Nodes { + if strings.ToLower(g.Nodes[i].Label) == labelLower { + n := g.Nodes[i] + target = &n + break + } + } + } + + if target == nil { + return nil, nil //nolint:nilnil // caller checks nil to detect not-found + } + + peek := &NodePeek{Node: *target} + + for _, e := range g.Edges { + switch { + case e.Source == target.ID: + peer := nodeByID[e.Target] + peek.EdgesOut = append(peek.EdgesOut, EdgePeek{ + Edge: e, + PeerID: e.Target, + PeerLabel: peer.Label, + PeerType: peer.Type, + }) + case e.Target == target.ID: + peer := nodeByID[e.Source] + peek.EdgesIn = append(peek.EdgesIn, EdgePeek{ + Edge: e, + PeerID: e.Source, + PeerLabel: peer.Label, + PeerType: peer.Type, + }) + } + } + + return peek, nil +} + +// PeekList returns a lightweight summary of every node in the graph — +// ID, type, label, access count, age, and edge degree — sorted by access +// count descending. Useful for scanning the graph before pruning. +func PeekList(rootDir string) ([]NodePeek, error) { + g, err := load(rootDir) + if err != nil { + return nil, err + } + + nodeByID := indexNodes(g) + + // Count in/out degree per node. + outDeg := make(map[string]int, len(g.Nodes)) + inDeg := make(map[string]int, len(g.Nodes)) + edgesOut := make(map[string][]EdgePeek, len(g.Nodes)) + edgesIn := make(map[string][]EdgePeek, len(g.Nodes)) + + for _, e := range g.Edges { + peer := nodeByID[e.Target] + edgesOut[e.Source] = append(edgesOut[e.Source], EdgePeek{ + Edge: e, PeerID: e.Target, PeerLabel: peer.Label, PeerType: peer.Type, + }) + outDeg[e.Source]++ + + peer = nodeByID[e.Source] + edgesIn[e.Target] = append(edgesIn[e.Target], EdgePeek{ + Edge: e, PeerID: e.Source, PeerLabel: peer.Label, PeerType: peer.Type, + }) + inDeg[e.Target]++ + } + + peeks := make([]NodePeek, 0, len(g.Nodes)) + for _, n := range g.Nodes { + peeks = append(peeks, NodePeek{ + Node: n, + EdgesOut: edgesOut[n.ID], + EdgesIn: edgesIn[n.ID], + }) + } + + // Sort by access count desc, then label asc for stable output. + sortNodePeeks(peeks) + + _ = outDeg // retained for future filtering options + _ = inDeg + + return peeks, nil +} + +// FormatPeek renders a NodePeek as a human-readable block suitable for +// display in a terminal or MCP tool response. +func FormatPeek(p *NodePeek) string { + if p == nil { + return "❌ Node not found." + } + n := p.Node + age := time.Since(n.CreatedAt).Round(time.Hour) + + var b strings.Builder + fmt.Fprintf(&b, "┌─ [%s] %s\n", n.Type, n.Label) + fmt.Fprintf(&b, "│ ID: %s\n", n.ID) + fmt.Fprintf(&b, "│ Accessed: %dx │ Age: %s │ Updated: %s\n", + n.AccessCount, + age, + n.UpdatedAt.Format("2006-01-02 15:04"), + ) + if len(n.Metadata) > 0 { + fmt.Fprintf(&b, "│ Metadata: %s\n", formatMetadata(n.Metadata)) + } + fmt.Fprintf(&b, "│\n│ Content:\n│ %s\n", + strings.ReplaceAll(n.Content, "\n", "\n│ ")) + + if len(p.EdgesOut) > 0 { + fmt.Fprintf(&b, "│\n│ Out (%d):\n", len(p.EdgesOut)) + for _, ep := range p.EdgesOut { + fmt.Fprintf(&b, "│ ──[%s w:%.2f]──▶ [%s] %s\n", + ep.Edge.Relation, ep.Edge.Weight, ep.PeerType, ep.PeerLabel) + } + } + if len(p.EdgesIn) > 0 { + fmt.Fprintf(&b, "│\n│ In (%d):\n", len(p.EdgesIn)) + for _, ep := range p.EdgesIn { + fmt.Fprintf(&b, "│ [%s] %s ──[%s w:%.2f]──▶\n", + ep.PeerType, ep.PeerLabel, ep.Edge.Relation, ep.Edge.Weight) + } + } + b.WriteString("└─") + return b.String() +} + +// FormatPeekList renders a PeekList result as a compact table with one node +// per line, suitable for scanning before a prune pass. +func FormatPeekList(peeks []NodePeek) string { + if len(peeks) == 0 { + return "Graph is empty." + } + var b strings.Builder + fmt.Fprintf(&b, "%-12s %-10s %-32s %7s %4s %4s\n", + "TYPE", "ID (short)", "LABEL", "ACCESSED", "OUT", "IN") + b.WriteString(strings.Repeat("─", 76) + "\n") + for _, p := range peeks { + shortID := p.Node.ID + if len(shortID) > 10 { + shortID = shortID[:10] + "…" + } + label := p.Node.Label + if len(label) > 32 { + label = label[:31] + "…" + } + fmt.Fprintf(&b, "%-12s %-10s %-32s %7dx %4d %4d\n", + p.Node.Type, shortID, label, + p.Node.AccessCount, len(p.EdgesOut), len(p.EdgesIn)) + } + fmt.Fprintf(&b, "\n%d node(s) total\n", len(peeks)) + return b.String() +} + +// --- helpers ----------------------------------------------------------------- + +func formatMetadata(m map[string]string) string { + parts := make([]string, 0, len(m)) + for k, v := range m { + parts = append(parts, k+"="+v) + } + return strings.Join(parts, " ") +} + +func sortNodePeeks(peeks []NodePeek) { + // Insertion sort is fine for the typical small N here. + for i := 1; i < len(peeks); i++ { + for j := i; j > 0; j-- { + a, b := peeks[j-1], peeks[j] + if a.Node.AccessCount < b.Node.AccessCount || + (a.Node.AccessCount == b.Node.AccessCount && a.Node.Label > b.Node.Label) { + peeks[j-1], peeks[j] = peeks[j], peeks[j-1] + } else { + break + } + } + } +} From 7baaa7f7773f021326ab7bf9678c0bf9e826eee0 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 00:05:43 -0400 Subject: [PATCH 03/32] Add files via upload --- cmd/tools.go | 245 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 cmd/tools.go diff --git a/cmd/tools.go b/cmd/tools.go new file mode 100644 index 0000000..9185945 --- /dev/null +++ b/cmd/tools.go @@ -0,0 +1,245 @@ +// Package memorygraph — MCP tool wrappers. +// Each exported Tool* function is the Go equivalent of the TypeScript tool +// functions in tools/memory-tools.ts and follows the same output format so +// that callers get identical text responses. +package memorygraph + +import ( + "fmt" + "strings" +) + +// --- Tool option structs ------------------------------------------------------ + +// UpsertMemoryNodeOptions mirrors UpsertMemoryNodeOptions in the TS source. +type UpsertMemoryNodeOptions struct { + RootDir string + Type NodeType + Label string + Content string + Metadata map[string]string +} + +// CreateRelationOptions mirrors CreateRelationOptions in the TS source. +type CreateRelationOptions struct { + RootDir string + SourceID string + TargetID string + Relation RelationType + Weight float64 + Metadata map[string]string +} + +// SearchMemoryGraphOptions mirrors SearchMemoryGraphOptions in the TS source. +type SearchMemoryGraphOptions struct { + RootDir string + Query string + MaxDepth int + TopK int + EdgeFilter []RelationType +} + +// PruneStaleLinksOptions mirrors PruneStaleLinksOptions in the TS source. +type PruneStaleLinksOptions struct { + RootDir string + Threshold float64 +} + +// InterlinkedItem is a single entry for AddInterlinkedContext. +type InterlinkedItem struct { + Type NodeType + Label string + Content string + Metadata map[string]string +} + +// AddInterlinkedContextOptions mirrors AddInterlinkedContextOptions in the TS source. +type AddInterlinkedContextOptions struct { + RootDir string + Items []InterlinkedItem + AutoLink bool +} + +// RetrieveWithTraversalOptions mirrors RetrieveWithTraversalOptions in the TS source. +type RetrieveWithTraversalOptions struct { + RootDir string + StartNodeID string + MaxDepth int + EdgeFilter []RelationType +} + +// --- Formatters -------------------------------------------------------------- + +func formatTraversalResult(r TraversalResult) string { + content := r.Node.Content + if len(content) > 120 { + content = content[:120] + "..." + } + lines := []string{ + fmt.Sprintf(" [%s] %s (depth: %d, score: %.2f)", r.Node.Type, r.Node.Label, r.Depth, r.RelevanceScore), + fmt.Sprintf(" Content: %s", content), + } + if len(r.PathRelations) > 1 { + lines = append(lines, fmt.Sprintf(" Path: %s", strings.Join(r.PathRelations, " "))) + } + lines = append(lines, fmt.Sprintf(" ID: %s | Accessed: %dx", r.Node.ID, r.Node.AccessCount)) + return strings.Join(lines, "\n") +} + +// --- Tool implementations ---------------------------------------------------- + +// ToolUpsertMemoryNode creates or updates a memory node and returns a +// human-readable summary including updated graph stats. +func ToolUpsertMemoryNode(opts UpsertMemoryNodeOptions) (string, error) { + node, err := UpsertNode(opts.RootDir, opts.Type, opts.Label, opts.Content, opts.Metadata) + if err != nil { + return "", err + } + stats, err := GetGraphStats(opts.RootDir) + if err != nil { + return "", err + } + return strings.Join([]string{ + fmt.Sprintf("✅ Memory node upserted: %s", node.Label), + fmt.Sprintf(" ID: %s", node.ID), + fmt.Sprintf(" Type: %s", node.Type), + fmt.Sprintf(" Access count: %d", node.AccessCount), + fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), + }, "\n"), nil +} + +// ToolCreateRelation adds a directed edge between two existing nodes. +func ToolCreateRelation(opts CreateRelationOptions) (string, error) { + edge, err := CreateRelation(opts.RootDir, opts.SourceID, opts.TargetID, opts.Relation, opts.Weight, opts.Metadata) + if err != nil { + return "", err + } + if edge == nil { + return fmt.Sprintf("❌ Failed: one or both node IDs not found (source: %s, target: %s)", + opts.SourceID, opts.TargetID), nil + } + stats, err := GetGraphStats(opts.RootDir) + if err != nil { + return "", err + } + return strings.Join([]string{ + fmt.Sprintf("✅ Relation created: %s --[%s]--> %s", opts.SourceID, edge.Relation, opts.TargetID), + fmt.Sprintf(" Edge ID: %s", edge.ID), + fmt.Sprintf(" Weight: %.2f", edge.Weight), + fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), + }, "\n"), nil +} + +// ToolSearchMemoryGraph searches the graph and returns direct matches plus +// one-hop neighbors, formatted identically to the TypeScript version. +func ToolSearchMemoryGraph(opts SearchMemoryGraphOptions) (string, error) { + result, err := SearchGraph(opts.RootDir, opts.Query, opts.MaxDepth, opts.TopK, opts.EdgeFilter) + if err != nil { + return "", err + } + if len(result.Direct) == 0 { + return fmt.Sprintf("No memory nodes found for: %q\nGraph has %d nodes, %d edges.", + opts.Query, result.TotalNodes, result.TotalEdges), nil + } + + sections := []string{ + fmt.Sprintf("Memory Graph Search: %q", opts.Query), + fmt.Sprintf("Graph: %d nodes, %d edges\n", result.TotalNodes, result.TotalEdges), + "Direct Matches:", + } + for _, hit := range result.Direct { + sections = append(sections, formatTraversalResult(hit)) + } + if len(result.Neighbors) > 0 { + sections = append(sections, "\nLinked Neighbors:") + for _, neighbor := range result.Neighbors { + sections = append(sections, formatTraversalResult(neighbor)) + } + } + return strings.Join(sections, "\n"), nil +} + +// ToolPruneStaleLinks removes weak edges and orphaned nodes. +func ToolPruneStaleLinks(opts PruneStaleLinksOptions) (string, error) { + result, err := PruneStaleLinks(opts.RootDir, opts.Threshold) + if err != nil { + return "", err + } + return strings.Join([]string{ + "🧹 Pruning complete", + fmt.Sprintf(" Removed: %d stale links/orphan nodes", result.Removed), + fmt.Sprintf(" Remaining edges: %d", result.Remaining), + }, "\n"), nil +} + +// ToolAddInterlinkedContext bulk-upserts nodes and optionally auto-links them +// by content similarity (threshold ≥ 0.72). +func ToolAddInterlinkedContext(opts AddInterlinkedContextOptions) (string, error) { + items := make([]struct { + Type NodeType + Label string + Content string + Metadata map[string]string + }, len(opts.Items)) + for i, it := range opts.Items { + items[i].Type = it.Type + items[i].Label = it.Label + items[i].Content = it.Content + items[i].Metadata = it.Metadata + } + + result, err := AddInterlinkedContext(opts.RootDir, items, opts.AutoLink) + if err != nil { + return "", err + } + + sections := []string{fmt.Sprintf("✅ Added %d interlinked nodes", len(result.Nodes))} + if len(result.Edges) > 0 { + sections = append(sections, fmt.Sprintf(" Auto-linked: %d similarity edges (threshold ≥ 0.72)", len(result.Edges))) + } else { + sections = append(sections, " No auto-links above threshold") + } + sections = append(sections, "\nNodes:") + for _, n := range result.Nodes { + sections = append(sections, fmt.Sprintf(" [%s] %s → %s", n.Type, n.Label, n.ID)) + } + if len(result.Edges) > 0 { + sections = append(sections, "\nEdges:") + for _, e := range result.Edges { + sections = append(sections, fmt.Sprintf(" %s --[%s w:%.2f]--> %s", + e.Source, e.Relation, e.Weight, e.Target)) + } + } + + stats, err := GetGraphStats(opts.RootDir) + if err != nil { + return "", err + } + sections = append(sections, fmt.Sprintf("\nGraph total: %d nodes, %d edges", stats.Nodes, stats.Edges)) + return strings.Join(sections, "\n"), nil +} + +// ToolRetrieveWithTraversal starts a BFS from startNodeID and returns all +// reachable nodes up to maxDepth, formatted with path context and scores. +func ToolRetrieveWithTraversal(opts RetrieveWithTraversalOptions) (string, error) { + results, err := RetrieveWithTraversal(opts.RootDir, opts.StartNodeID, opts.MaxDepth, opts.EdgeFilter) + if err != nil { + return "", err + } + if len(results) == 0 { + return fmt.Sprintf("❌ Node not found: %s", opts.StartNodeID), nil + } + + maxDepth := opts.MaxDepth + if maxDepth <= 0 { + maxDepth = 2 + } + + sections := []string{ + fmt.Sprintf("Traversal from: %s (depth limit: %d)\n", results[0].Node.Label, maxDepth), + } + for _, r := range results { + sections = append(sections, formatTraversalResult(r)) + } + return strings.Join(sections, "\n"), nil +} From 643341ecdaffbbd2c64bb1a988025972ae0fdbc7 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 00:34:11 -0400 Subject: [PATCH 04/32] Update cmd/Memory-graph.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- cmd/Memory-graph.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/cmd/Memory-graph.go b/cmd/Memory-graph.go index 351b79a..305d573 100644 --- a/cmd/Memory-graph.go +++ b/cmd/Memory-graph.go @@ -143,9 +143,29 @@ func save(rootDir string, g *graphData) error { if err != nil { return fmt.Errorf("memorygraph: marshal: %w", err) } - if err := os.WriteFile(path, data, 0o644); err != nil { + tmp, err := os.CreateTemp(filepath.Dir(path), ".memory-graph-*.json.tmp") + if err != nil { + return fmt.Errorf("memorygraph: tempfile: %w", err) + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) return fmt.Errorf("memorygraph: write: %w", err) } + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("memorygraph: fsync: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("memorygraph: close: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return fmt.Errorf("memorygraph: rename: %w", err) + } return nil } From b5c37d6eb852cbf6a6bda9383845d9d006107d07 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 00:34:57 -0400 Subject: [PATCH 05/32] Update cmd/Peek.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- cmd/Peek.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/cmd/Peek.go b/cmd/Peek.go index 5bd12af..c068557 100644 --- a/cmd/Peek.go +++ b/cmd/Peek.go @@ -99,9 +99,6 @@ func PeekList(rootDir string) ([]NodePeek, error) { nodeByID := indexNodes(g) - // Count in/out degree per node. - outDeg := make(map[string]int, len(g.Nodes)) - inDeg := make(map[string]int, len(g.Nodes)) edgesOut := make(map[string][]EdgePeek, len(g.Nodes)) edgesIn := make(map[string][]EdgePeek, len(g.Nodes)) @@ -110,13 +107,11 @@ func PeekList(rootDir string) ([]NodePeek, error) { edgesOut[e.Source] = append(edgesOut[e.Source], EdgePeek{ Edge: e, PeerID: e.Target, PeerLabel: peer.Label, PeerType: peer.Type, }) - outDeg[e.Source]++ peer = nodeByID[e.Source] edgesIn[e.Target] = append(edgesIn[e.Target], EdgePeek{ Edge: e, PeerID: e.Source, PeerLabel: peer.Label, PeerType: peer.Type, }) - inDeg[e.Target]++ } peeks := make([]NodePeek, 0, len(g.Nodes)) @@ -131,9 +126,6 @@ func PeekList(rootDir string) ([]NodePeek, error) { // Sort by access count desc, then label asc for stable output. sortNodePeeks(peeks) - _ = outDeg // retained for future filtering options - _ = inDeg - return peeks, nil } From 703838afc044a20a7bfe8ad0257c5c87b27d5358 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 08:41:05 -0400 Subject: [PATCH 06/32] Create Tools.go --- cmd/Internal/Memorygraph/Tools.go | 1 + 1 file changed, 1 insertion(+) create mode 100644 cmd/Internal/Memorygraph/Tools.go diff --git a/cmd/Internal/Memorygraph/Tools.go b/cmd/Internal/Memorygraph/Tools.go new file mode 100644 index 0000000..917ac73 --- /dev/null +++ b/cmd/Internal/Memorygraph/Tools.go @@ -0,0 +1 @@ +move file here From 22c9d4f46b7cd3df1cf25aa7ec07e0622017495c Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 08:42:34 -0400 Subject: [PATCH 07/32] Update Tools.go Memorygraph is uppercase --- cmd/Internal/Memorygraph/Tools.go | 246 +++++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 1 deletion(-) diff --git a/cmd/Internal/Memorygraph/Tools.go b/cmd/Internal/Memorygraph/Tools.go index 917ac73..9e036f2 100644 --- a/cmd/Internal/Memorygraph/Tools.go +++ b/cmd/Internal/Memorygraph/Tools.go @@ -1 +1,245 @@ -move file here +// Package memorygraph — MCP tool wrappers. +// Each exported Tool* function is the Go equivalent of the TypeScript tool +// functions in tools/memory-tools.ts and follows the same output format so +// that callers get identical text responses. +package Memorygraph + +import ( + "fmt" + "strings" +) + +// --- Tool option structs ------------------------------------------------------ + +// UpsertMemoryNodeOptions mirrors UpsertMemoryNodeOptions in the TS source. +type UpsertMemoryNodeOptions struct { + RootDir string + Type NodeType + Label string + Content string + Metadata map[string]string +} + +// CreateRelationOptions mirrors CreateRelationOptions in the TS source. +type CreateRelationOptions struct { + RootDir string + SourceID string + TargetID string + Relation RelationType + Weight float64 + Metadata map[string]string +} + +// SearchMemoryGraphOptions mirrors SearchMemoryGraphOptions in the TS source. +type SearchMemoryGraphOptions struct { + RootDir string + Query string + MaxDepth int + TopK int + EdgeFilter []RelationType +} + +// PruneStaleLinksOptions mirrors PruneStaleLinksOptions in the TS source. +type PruneStaleLinksOptions struct { + RootDir string + Threshold float64 +} + +// InterlinkedItem is a single entry for AddInterlinkedContext. +type InterlinkedItem struct { + Type NodeType + Label string + Content string + Metadata map[string]string +} + +// AddInterlinkedContextOptions mirrors AddInterlinkedContextOptions in the TS source. +type AddInterlinkedContextOptions struct { + RootDir string + Items []InterlinkedItem + AutoLink bool +} + +// RetrieveWithTraversalOptions mirrors RetrieveWithTraversalOptions in the TS source. +type RetrieveWithTraversalOptions struct { + RootDir string + StartNodeID string + MaxDepth int + EdgeFilter []RelationType +} + +// --- Formatters -------------------------------------------------------------- + +func formatTraversalResult(r TraversalResult) string { + content := r.Node.Content + if len(content) > 120 { + content = content[:120] + "..." + } + lines := []string{ + fmt.Sprintf(" [%s] %s (depth: %d, score: %.2f)", r.Node.Type, r.Node.Label, r.Depth, r.RelevanceScore), + fmt.Sprintf(" Content: %s", content), + } + if len(r.PathRelations) > 1 { + lines = append(lines, fmt.Sprintf(" Path: %s", strings.Join(r.PathRelations, " "))) + } + lines = append(lines, fmt.Sprintf(" ID: %s | Accessed: %dx", r.Node.ID, r.Node.AccessCount)) + return strings.Join(lines, "\n") +} + +// --- Tool implementations ---------------------------------------------------- + +// ToolUpsertMemoryNode creates or updates a memory node and returns a +// human-readable summary including updated graph stats. +func ToolUpsertMemoryNode(opts UpsertMemoryNodeOptions) (string, error) { + node, err := UpsertNode(opts.RootDir, opts.Type, opts.Label, opts.Content, opts.Metadata) + if err != nil { + return "", err + } + stats, err := GetGraphStats(opts.RootDir) + if err != nil { + return "", err + } + return strings.Join([]string{ + fmt.Sprintf("✅ Memory node upserted: %s", node.Label), + fmt.Sprintf(" ID: %s", node.ID), + fmt.Sprintf(" Type: %s", node.Type), + fmt.Sprintf(" Access count: %d", node.AccessCount), + fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), + }, "\n"), nil +} + +// ToolCreateRelation adds a directed edge between two existing nodes. +func ToolCreateRelation(opts CreateRelationOptions) (string, error) { + edge, err := CreateRelation(opts.RootDir, opts.SourceID, opts.TargetID, opts.Relation, opts.Weight, opts.Metadata) + if err != nil { + return "", err + } + if edge == nil { + return fmt.Sprintf("❌ Failed: one or both node IDs not found (source: %s, target: %s)", + opts.SourceID, opts.TargetID), nil + } + stats, err := GetGraphStats(opts.RootDir) + if err != nil { + return "", err + } + return strings.Join([]string{ + fmt.Sprintf("✅ Relation created: %s --[%s]--> %s", opts.SourceID, edge.Relation, opts.TargetID), + fmt.Sprintf(" Edge ID: %s", edge.ID), + fmt.Sprintf(" Weight: %.2f", edge.Weight), + fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), + }, "\n"), nil +} + +// ToolSearchMemoryGraph searches the graph and returns direct matches plus +// one-hop neighbors, formatted identically to the TypeScript version. +func ToolSearchMemoryGraph(opts SearchMemoryGraphOptions) (string, error) { + result, err := SearchGraph(opts.RootDir, opts.Query, opts.MaxDepth, opts.TopK, opts.EdgeFilter) + if err != nil { + return "", err + } + if len(result.Direct) == 0 { + return fmt.Sprintf("No memory nodes found for: %q\nGraph has %d nodes, %d edges.", + opts.Query, result.TotalNodes, result.TotalEdges), nil + } + + sections := []string{ + fmt.Sprintf("Memory Graph Search: %q", opts.Query), + fmt.Sprintf("Graph: %d nodes, %d edges\n", result.TotalNodes, result.TotalEdges), + "Direct Matches:", + } + for _, hit := range result.Direct { + sections = append(sections, formatTraversalResult(hit)) + } + if len(result.Neighbors) > 0 { + sections = append(sections, "\nLinked Neighbors:") + for _, neighbor := range result.Neighbors { + sections = append(sections, formatTraversalResult(neighbor)) + } + } + return strings.Join(sections, "\n"), nil +} + +// ToolPruneStaleLinks removes weak edges and orphaned nodes. +func ToolPruneStaleLinks(opts PruneStaleLinksOptions) (string, error) { + result, err := PruneStaleLinks(opts.RootDir, opts.Threshold) + if err != nil { + return "", err + } + return strings.Join([]string{ + "🧹 Pruning complete", + fmt.Sprintf(" Removed: %d stale links/orphan nodes", result.Removed), + fmt.Sprintf(" Remaining edges: %d", result.Remaining), + }, "\n"), nil +} + +// ToolAddInterlinkedContext bulk-upserts nodes and optionally auto-links them +// by content similarity (threshold ≥ 0.72). +func ToolAddInterlinkedContext(opts AddInterlinkedContextOptions) (string, error) { + items := make([]struct { + Type NodeType + Label string + Content string + Metadata map[string]string + }, len(opts.Items)) + for i, it := range opts.Items { + items[i].Type = it.Type + items[i].Label = it.Label + items[i].Content = it.Content + items[i].Metadata = it.Metadata + } + + result, err := AddInterlinkedContext(opts.RootDir, items, opts.AutoLink) + if err != nil { + return "", err + } + + sections := []string{fmt.Sprintf("✅ Added %d interlinked nodes", len(result.Nodes))} + if len(result.Edges) > 0 { + sections = append(sections, fmt.Sprintf(" Auto-linked: %d similarity edges (threshold ≥ 0.72)", len(result.Edges))) + } else { + sections = append(sections, " No auto-links above threshold") + } + sections = append(sections, "\nNodes:") + for _, n := range result.Nodes { + sections = append(sections, fmt.Sprintf(" [%s] %s → %s", n.Type, n.Label, n.ID)) + } + if len(result.Edges) > 0 { + sections = append(sections, "\nEdges:") + for _, e := range result.Edges { + sections = append(sections, fmt.Sprintf(" %s --[%s w:%.2f]--> %s", + e.Source, e.Relation, e.Weight, e.Target)) + } + } + + stats, err := GetGraphStats(opts.RootDir) + if err != nil { + return "", err + } + sections = append(sections, fmt.Sprintf("\nGraph total: %d nodes, %d edges", stats.Nodes, stats.Edges)) + return strings.Join(sections, "\n"), nil +} + +// ToolRetrieveWithTraversal starts a BFS from startNodeID and returns all +// reachable nodes up to maxDepth, formatted with path context and scores. +func ToolRetrieveWithTraversal(opts RetrieveWithTraversalOptions) (string, error) { + results, err := RetrieveWithTraversal(opts.RootDir, opts.StartNodeID, opts.MaxDepth, opts.EdgeFilter) + if err != nil { + return "", err + } + if len(results) == 0 { + return fmt.Sprintf("❌ Node not found: %s", opts.StartNodeID), nil + } + + maxDepth := opts.MaxDepth + if maxDepth <= 0 { + maxDepth = 2 + } + + sections := []string{ + fmt.Sprintf("Traversal from: %s (depth limit: %d)\n", results[0].Node.Label, maxDepth), + } + for _, r := range results { + sections = append(sections, formatTraversalResult(r)) + } + return strings.Join(sections, "\n"), nil +} From aaf6007efb982c0a6a5050381e616c4d5dbf72c9 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 08:53:12 -0400 Subject: [PATCH 08/32] Create Peek.go --- internal/Memorygraph/Peek.go | 1 + 1 file changed, 1 insertion(+) create mode 100644 internal/Memorygraph/Peek.go diff --git a/internal/Memorygraph/Peek.go b/internal/Memorygraph/Peek.go new file mode 100644 index 0000000..258cd57 --- /dev/null +++ b/internal/Memorygraph/Peek.go @@ -0,0 +1 @@ +todo From 6345f35aa84631baca854c01ada00d5d5ac8fbc5 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 08:53:35 -0400 Subject: [PATCH 09/32] Delete cmd/Internal/Memorygraph directory --- cmd/Internal/Memorygraph/Tools.go | 245 ------------------------------ 1 file changed, 245 deletions(-) delete mode 100644 cmd/Internal/Memorygraph/Tools.go diff --git a/cmd/Internal/Memorygraph/Tools.go b/cmd/Internal/Memorygraph/Tools.go deleted file mode 100644 index 9e036f2..0000000 --- a/cmd/Internal/Memorygraph/Tools.go +++ /dev/null @@ -1,245 +0,0 @@ -// Package memorygraph — MCP tool wrappers. -// Each exported Tool* function is the Go equivalent of the TypeScript tool -// functions in tools/memory-tools.ts and follows the same output format so -// that callers get identical text responses. -package Memorygraph - -import ( - "fmt" - "strings" -) - -// --- Tool option structs ------------------------------------------------------ - -// UpsertMemoryNodeOptions mirrors UpsertMemoryNodeOptions in the TS source. -type UpsertMemoryNodeOptions struct { - RootDir string - Type NodeType - Label string - Content string - Metadata map[string]string -} - -// CreateRelationOptions mirrors CreateRelationOptions in the TS source. -type CreateRelationOptions struct { - RootDir string - SourceID string - TargetID string - Relation RelationType - Weight float64 - Metadata map[string]string -} - -// SearchMemoryGraphOptions mirrors SearchMemoryGraphOptions in the TS source. -type SearchMemoryGraphOptions struct { - RootDir string - Query string - MaxDepth int - TopK int - EdgeFilter []RelationType -} - -// PruneStaleLinksOptions mirrors PruneStaleLinksOptions in the TS source. -type PruneStaleLinksOptions struct { - RootDir string - Threshold float64 -} - -// InterlinkedItem is a single entry for AddInterlinkedContext. -type InterlinkedItem struct { - Type NodeType - Label string - Content string - Metadata map[string]string -} - -// AddInterlinkedContextOptions mirrors AddInterlinkedContextOptions in the TS source. -type AddInterlinkedContextOptions struct { - RootDir string - Items []InterlinkedItem - AutoLink bool -} - -// RetrieveWithTraversalOptions mirrors RetrieveWithTraversalOptions in the TS source. -type RetrieveWithTraversalOptions struct { - RootDir string - StartNodeID string - MaxDepth int - EdgeFilter []RelationType -} - -// --- Formatters -------------------------------------------------------------- - -func formatTraversalResult(r TraversalResult) string { - content := r.Node.Content - if len(content) > 120 { - content = content[:120] + "..." - } - lines := []string{ - fmt.Sprintf(" [%s] %s (depth: %d, score: %.2f)", r.Node.Type, r.Node.Label, r.Depth, r.RelevanceScore), - fmt.Sprintf(" Content: %s", content), - } - if len(r.PathRelations) > 1 { - lines = append(lines, fmt.Sprintf(" Path: %s", strings.Join(r.PathRelations, " "))) - } - lines = append(lines, fmt.Sprintf(" ID: %s | Accessed: %dx", r.Node.ID, r.Node.AccessCount)) - return strings.Join(lines, "\n") -} - -// --- Tool implementations ---------------------------------------------------- - -// ToolUpsertMemoryNode creates or updates a memory node and returns a -// human-readable summary including updated graph stats. -func ToolUpsertMemoryNode(opts UpsertMemoryNodeOptions) (string, error) { - node, err := UpsertNode(opts.RootDir, opts.Type, opts.Label, opts.Content, opts.Metadata) - if err != nil { - return "", err - } - stats, err := GetGraphStats(opts.RootDir) - if err != nil { - return "", err - } - return strings.Join([]string{ - fmt.Sprintf("✅ Memory node upserted: %s", node.Label), - fmt.Sprintf(" ID: %s", node.ID), - fmt.Sprintf(" Type: %s", node.Type), - fmt.Sprintf(" Access count: %d", node.AccessCount), - fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), - }, "\n"), nil -} - -// ToolCreateRelation adds a directed edge between two existing nodes. -func ToolCreateRelation(opts CreateRelationOptions) (string, error) { - edge, err := CreateRelation(opts.RootDir, opts.SourceID, opts.TargetID, opts.Relation, opts.Weight, opts.Metadata) - if err != nil { - return "", err - } - if edge == nil { - return fmt.Sprintf("❌ Failed: one or both node IDs not found (source: %s, target: %s)", - opts.SourceID, opts.TargetID), nil - } - stats, err := GetGraphStats(opts.RootDir) - if err != nil { - return "", err - } - return strings.Join([]string{ - fmt.Sprintf("✅ Relation created: %s --[%s]--> %s", opts.SourceID, edge.Relation, opts.TargetID), - fmt.Sprintf(" Edge ID: %s", edge.ID), - fmt.Sprintf(" Weight: %.2f", edge.Weight), - fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), - }, "\n"), nil -} - -// ToolSearchMemoryGraph searches the graph and returns direct matches plus -// one-hop neighbors, formatted identically to the TypeScript version. -func ToolSearchMemoryGraph(opts SearchMemoryGraphOptions) (string, error) { - result, err := SearchGraph(opts.RootDir, opts.Query, opts.MaxDepth, opts.TopK, opts.EdgeFilter) - if err != nil { - return "", err - } - if len(result.Direct) == 0 { - return fmt.Sprintf("No memory nodes found for: %q\nGraph has %d nodes, %d edges.", - opts.Query, result.TotalNodes, result.TotalEdges), nil - } - - sections := []string{ - fmt.Sprintf("Memory Graph Search: %q", opts.Query), - fmt.Sprintf("Graph: %d nodes, %d edges\n", result.TotalNodes, result.TotalEdges), - "Direct Matches:", - } - for _, hit := range result.Direct { - sections = append(sections, formatTraversalResult(hit)) - } - if len(result.Neighbors) > 0 { - sections = append(sections, "\nLinked Neighbors:") - for _, neighbor := range result.Neighbors { - sections = append(sections, formatTraversalResult(neighbor)) - } - } - return strings.Join(sections, "\n"), nil -} - -// ToolPruneStaleLinks removes weak edges and orphaned nodes. -func ToolPruneStaleLinks(opts PruneStaleLinksOptions) (string, error) { - result, err := PruneStaleLinks(opts.RootDir, opts.Threshold) - if err != nil { - return "", err - } - return strings.Join([]string{ - "🧹 Pruning complete", - fmt.Sprintf(" Removed: %d stale links/orphan nodes", result.Removed), - fmt.Sprintf(" Remaining edges: %d", result.Remaining), - }, "\n"), nil -} - -// ToolAddInterlinkedContext bulk-upserts nodes and optionally auto-links them -// by content similarity (threshold ≥ 0.72). -func ToolAddInterlinkedContext(opts AddInterlinkedContextOptions) (string, error) { - items := make([]struct { - Type NodeType - Label string - Content string - Metadata map[string]string - }, len(opts.Items)) - for i, it := range opts.Items { - items[i].Type = it.Type - items[i].Label = it.Label - items[i].Content = it.Content - items[i].Metadata = it.Metadata - } - - result, err := AddInterlinkedContext(opts.RootDir, items, opts.AutoLink) - if err != nil { - return "", err - } - - sections := []string{fmt.Sprintf("✅ Added %d interlinked nodes", len(result.Nodes))} - if len(result.Edges) > 0 { - sections = append(sections, fmt.Sprintf(" Auto-linked: %d similarity edges (threshold ≥ 0.72)", len(result.Edges))) - } else { - sections = append(sections, " No auto-links above threshold") - } - sections = append(sections, "\nNodes:") - for _, n := range result.Nodes { - sections = append(sections, fmt.Sprintf(" [%s] %s → %s", n.Type, n.Label, n.ID)) - } - if len(result.Edges) > 0 { - sections = append(sections, "\nEdges:") - for _, e := range result.Edges { - sections = append(sections, fmt.Sprintf(" %s --[%s w:%.2f]--> %s", - e.Source, e.Relation, e.Weight, e.Target)) - } - } - - stats, err := GetGraphStats(opts.RootDir) - if err != nil { - return "", err - } - sections = append(sections, fmt.Sprintf("\nGraph total: %d nodes, %d edges", stats.Nodes, stats.Edges)) - return strings.Join(sections, "\n"), nil -} - -// ToolRetrieveWithTraversal starts a BFS from startNodeID and returns all -// reachable nodes up to maxDepth, formatted with path context and scores. -func ToolRetrieveWithTraversal(opts RetrieveWithTraversalOptions) (string, error) { - results, err := RetrieveWithTraversal(opts.RootDir, opts.StartNodeID, opts.MaxDepth, opts.EdgeFilter) - if err != nil { - return "", err - } - if len(results) == 0 { - return fmt.Sprintf("❌ Node not found: %s", opts.StartNodeID), nil - } - - maxDepth := opts.MaxDepth - if maxDepth <= 0 { - maxDepth = 2 - } - - sections := []string{ - fmt.Sprintf("Traversal from: %s (depth limit: %d)\n", results[0].Node.Label, maxDepth), - } - for _, r := range results { - sections = append(sections, formatTraversalResult(r)) - } - return strings.Join(sections, "\n"), nil -} From 5d8f0262055e83a95aab057bb61cae5a578ffa59 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 08:54:23 -0400 Subject: [PATCH 10/32] Update Peek.go --- internal/Memorygraph/Peek.go | 224 ++++++++++++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 1 deletion(-) diff --git a/internal/Memorygraph/Peek.go b/internal/Memorygraph/Peek.go index 258cd57..c068557 100644 --- a/internal/Memorygraph/Peek.go +++ b/internal/Memorygraph/Peek.go @@ -1 +1,223 @@ -todo +package memorygraph + +import ( + "fmt" + "strings" + "time" +) + +// NodePeek is a full snapshot of a node and all its edges, returned by Peek. +type NodePeek struct { + Node Node + EdgesOut []EdgePeek // edges where this node is the source + EdgesIn []EdgePeek // edges where this node is the target +} + +// EdgePeek is a human-readable summary of a single edge and its peer node. +type EdgePeek struct { + Edge Edge + PeerID string + PeerLabel string + PeerType NodeType +} + +// PeekOptions controls what Peek returns. +type PeekOptions struct { + RootDir string + // NodeID takes priority if set. + NodeID string + // Label is used for lookup when NodeID is empty (first match wins). + Label string +} + +// Peek returns a full NodePeek for the requested node: its content, metadata, +// access stats, and every inbound/outbound edge with peer labels resolved. +// Returns nil if the node cannot be found. +func Peek(opts PeekOptions) (*NodePeek, error) { + g, err := load(opts.RootDir) + if err != nil { + return nil, err + } + + nodeByID := indexNodes(g) + + // Resolve target node. + var target *Node + if opts.NodeID != "" { + if n, ok := nodeByID[opts.NodeID]; ok { + target = &n + } + } else if opts.Label != "" { + labelLower := strings.ToLower(opts.Label) + for i := range g.Nodes { + if strings.ToLower(g.Nodes[i].Label) == labelLower { + n := g.Nodes[i] + target = &n + break + } + } + } + + if target == nil { + return nil, nil //nolint:nilnil // caller checks nil to detect not-found + } + + peek := &NodePeek{Node: *target} + + for _, e := range g.Edges { + switch { + case e.Source == target.ID: + peer := nodeByID[e.Target] + peek.EdgesOut = append(peek.EdgesOut, EdgePeek{ + Edge: e, + PeerID: e.Target, + PeerLabel: peer.Label, + PeerType: peer.Type, + }) + case e.Target == target.ID: + peer := nodeByID[e.Source] + peek.EdgesIn = append(peek.EdgesIn, EdgePeek{ + Edge: e, + PeerID: e.Source, + PeerLabel: peer.Label, + PeerType: peer.Type, + }) + } + } + + return peek, nil +} + +// PeekList returns a lightweight summary of every node in the graph — +// ID, type, label, access count, age, and edge degree — sorted by access +// count descending. Useful for scanning the graph before pruning. +func PeekList(rootDir string) ([]NodePeek, error) { + g, err := load(rootDir) + if err != nil { + return nil, err + } + + nodeByID := indexNodes(g) + + edgesOut := make(map[string][]EdgePeek, len(g.Nodes)) + edgesIn := make(map[string][]EdgePeek, len(g.Nodes)) + + for _, e := range g.Edges { + peer := nodeByID[e.Target] + edgesOut[e.Source] = append(edgesOut[e.Source], EdgePeek{ + Edge: e, PeerID: e.Target, PeerLabel: peer.Label, PeerType: peer.Type, + }) + + peer = nodeByID[e.Source] + edgesIn[e.Target] = append(edgesIn[e.Target], EdgePeek{ + Edge: e, PeerID: e.Source, PeerLabel: peer.Label, PeerType: peer.Type, + }) + } + + peeks := make([]NodePeek, 0, len(g.Nodes)) + for _, n := range g.Nodes { + peeks = append(peeks, NodePeek{ + Node: n, + EdgesOut: edgesOut[n.ID], + EdgesIn: edgesIn[n.ID], + }) + } + + // Sort by access count desc, then label asc for stable output. + sortNodePeeks(peeks) + + return peeks, nil +} + +// FormatPeek renders a NodePeek as a human-readable block suitable for +// display in a terminal or MCP tool response. +func FormatPeek(p *NodePeek) string { + if p == nil { + return "❌ Node not found." + } + n := p.Node + age := time.Since(n.CreatedAt).Round(time.Hour) + + var b strings.Builder + fmt.Fprintf(&b, "┌─ [%s] %s\n", n.Type, n.Label) + fmt.Fprintf(&b, "│ ID: %s\n", n.ID) + fmt.Fprintf(&b, "│ Accessed: %dx │ Age: %s │ Updated: %s\n", + n.AccessCount, + age, + n.UpdatedAt.Format("2006-01-02 15:04"), + ) + if len(n.Metadata) > 0 { + fmt.Fprintf(&b, "│ Metadata: %s\n", formatMetadata(n.Metadata)) + } + fmt.Fprintf(&b, "│\n│ Content:\n│ %s\n", + strings.ReplaceAll(n.Content, "\n", "\n│ ")) + + if len(p.EdgesOut) > 0 { + fmt.Fprintf(&b, "│\n│ Out (%d):\n", len(p.EdgesOut)) + for _, ep := range p.EdgesOut { + fmt.Fprintf(&b, "│ ──[%s w:%.2f]──▶ [%s] %s\n", + ep.Edge.Relation, ep.Edge.Weight, ep.PeerType, ep.PeerLabel) + } + } + if len(p.EdgesIn) > 0 { + fmt.Fprintf(&b, "│\n│ In (%d):\n", len(p.EdgesIn)) + for _, ep := range p.EdgesIn { + fmt.Fprintf(&b, "│ [%s] %s ──[%s w:%.2f]──▶\n", + ep.PeerType, ep.PeerLabel, ep.Edge.Relation, ep.Edge.Weight) + } + } + b.WriteString("└─") + return b.String() +} + +// FormatPeekList renders a PeekList result as a compact table with one node +// per line, suitable for scanning before a prune pass. +func FormatPeekList(peeks []NodePeek) string { + if len(peeks) == 0 { + return "Graph is empty." + } + var b strings.Builder + fmt.Fprintf(&b, "%-12s %-10s %-32s %7s %4s %4s\n", + "TYPE", "ID (short)", "LABEL", "ACCESSED", "OUT", "IN") + b.WriteString(strings.Repeat("─", 76) + "\n") + for _, p := range peeks { + shortID := p.Node.ID + if len(shortID) > 10 { + shortID = shortID[:10] + "…" + } + label := p.Node.Label + if len(label) > 32 { + label = label[:31] + "…" + } + fmt.Fprintf(&b, "%-12s %-10s %-32s %7dx %4d %4d\n", + p.Node.Type, shortID, label, + p.Node.AccessCount, len(p.EdgesOut), len(p.EdgesIn)) + } + fmt.Fprintf(&b, "\n%d node(s) total\n", len(peeks)) + return b.String() +} + +// --- helpers ----------------------------------------------------------------- + +func formatMetadata(m map[string]string) string { + parts := make([]string, 0, len(m)) + for k, v := range m { + parts = append(parts, k+"="+v) + } + return strings.Join(parts, " ") +} + +func sortNodePeeks(peeks []NodePeek) { + // Insertion sort is fine for the typical small N here. + for i := 1; i < len(peeks); i++ { + for j := i; j > 0; j-- { + a, b := peeks[j-1], peeks[j] + if a.Node.AccessCount < b.Node.AccessCount || + (a.Node.AccessCount == b.Node.AccessCount && a.Node.Label > b.Node.Label) { + peeks[j-1], peeks[j] = peeks[j], peeks[j-1] + } else { + break + } + } + } +} From 03e6f4eb3c96903490b344ada61910b0fd8c588e Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 08:55:22 -0400 Subject: [PATCH 11/32] Update Memory-graph.go --- cmd/Memory-graph.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/Memory-graph.go b/cmd/Memory-graph.go index 305d573..bef6f7c 100644 --- a/cmd/Memory-graph.go +++ b/cmd/Memory-graph.go @@ -2,7 +2,7 @@ // Nodes represent typed knowledge units; edges are weighted, typed relations. // The graph is stored as a single JSON file under rootDir/.supermodel/memory-graph.json // and is safe for concurrent reads within a process (writes hold a mutex). -package memorygraph +package Memorygraph import ( "encoding/json" From 8bee89d4968c74c9e066e935d83bdf84461a2dce Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 08:56:56 -0400 Subject: [PATCH 12/32] Create Memorygraph.go Fixed uppercase bug for import Memorygraph --- internal/Memorygraph/Memorygraph.go | 651 ++++++++++++++++++++++++++++ 1 file changed, 651 insertions(+) create mode 100644 internal/Memorygraph/Memorygraph.go diff --git a/internal/Memorygraph/Memorygraph.go b/internal/Memorygraph/Memorygraph.go new file mode 100644 index 0000000..30de28c --- /dev/null +++ b/internal/Memorygraph/Memorygraph.go @@ -0,0 +1,651 @@ + Package memorygraph implements a persistent memory graph for interlinked RAG. +// Nodes represent typed knowledge units; edges are weighted, typed relations. +// The graph is stored as a single JSON file under rootDir/.supermodel/memory-graph.json +// and is safe for concurrent reads within a process (writes hold a mutex). +package Memorygraph + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// --- Types ------------------------------------------------------------------- + +// NodeType classifies what kind of knowledge a node represents. +type NodeType string + +const ( + NodeTypeFact NodeType = "fact" + NodeTypeConcept NodeType = "concept" + NodeTypeEntity NodeType = "entity" + NodeTypeEvent NodeType = "event" + NodeTypeProcedure NodeType = "procedure" + NodeTypeContext NodeType = "context" +) + +// RelationType classifies the semantic relationship between two nodes. +type RelationType string + +const ( + RelationRelatedTo RelationType = "related_to" + RelationDependsOn RelationType = "depends_on" + RelationPartOf RelationType = "part_of" + RelationLeadsTo RelationType = "leads_to" + RelationContrasts RelationType = "contrasts" + RelationSimilarTo RelationType = "similar_to" + RelationInstantiates RelationType = "instantiates" +) + +// Node is a single knowledge unit in the memory graph. +type Node struct { + ID string `json:"id"` + Type NodeType `json:"type"` + Label string `json:"label"` + Content string `json:"content"` + Metadata map[string]string `json:"metadata,omitempty"` + AccessCount int `json:"accessCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Edge is a directed, weighted relation between two nodes. +type Edge struct { + ID string `json:"id"` + Source string `json:"source"` + Target string `json:"target"` + Relation RelationType `json:"relation"` + Weight float64 `json:"weight"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// TraversalResult is a node reached during graph traversal, enriched with +// path context and a relevance score. +type TraversalResult struct { + Node Node + Depth int + RelevanceScore float64 + PathRelations []string // relation labels along the path from the start node +} + +// GraphStats summarises the current state of the graph. +type GraphStats struct { + Nodes int + Edges int +} + +// graphData is the on-disk format. +type graphData struct { + Nodes []Node `json:"nodes"` + Edges []Edge `json:"edges"` +} + +// --- Storage ----------------------------------------------------------------- + +const graphFile = ".supermodel/memory-graph.json" + +var ( + mu sync.RWMutex + cache = map[string]*graphData{} // rootDir → loaded graph +) + +func graphPath(rootDir string) string { + return filepath.Join(rootDir, graphFile) +} + +// load reads the graph for rootDir from disk (or returns the in-memory cache). +func load(rootDir string) (*graphData, error) { + mu.RLock() + if g, ok := cache[rootDir]; ok { + mu.RUnlock() + return g, nil + } + mu.RUnlock() + + mu.Lock() + defer mu.Unlock() + + // Double-checked locking. + if g, ok := cache[rootDir]; ok { + return g, nil + } + + path := graphPath(rootDir) + g := &graphData{} + + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("memorygraph: read %s: %w", path, err) + } + if err == nil { + if err := json.Unmarshal(data, g); err != nil { + return nil, fmt.Errorf("memorygraph: parse %s: %w", path, err) + } + } + + cache[rootDir] = g + return g, nil +} + +// save persists g to disk. Caller must hold mu (write lock). +func save(rootDir string, g *graphData) error { + path := graphPath(rootDir) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("memorygraph: mkdir: %w", err) + } + data, err := json.MarshalIndent(g, "", " ") + if err != nil { + return fmt.Errorf("memorygraph: marshal: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".memory-graph-*.json.tmp") + if err != nil { + return fmt.Errorf("memorygraph: tempfile: %w", err) + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("memorygraph: write: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("memorygraph: fsync: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("memorygraph: close: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return fmt.Errorf("memorygraph: rename: %w", err) + } + return nil +} + +// nodeID derives a stable deterministic ID from type+label. +func nodeID(t NodeType, label string) string { + return fmt.Sprintf("%s:%s", t, strings.ToLower(strings.ReplaceAll(label, " ", "_"))) +} + +// edgeID derives a stable deterministic ID from endpoints+relation. +func edgeID(source, target string, relation RelationType) string { + return fmt.Sprintf("%s--%s-->%s", source, relation, target) +} + +// --- Core operations --------------------------------------------------------- + +// UpsertNode creates or updates a node identified by (type, label). +// If the node already exists its content and metadata are updated in-place. +func UpsertNode(rootDir string, t NodeType, label, content string, metadata map[string]string) (*Node, error) { + mu.Lock() + defer mu.Unlock() + + g, err := loadLocked(rootDir) + if err != nil { + return nil, err + } + + id := nodeID(t, label) + now := time.Now().UTC() + + for i := range g.Nodes { + if g.Nodes[i].ID == id { + g.Nodes[i].Content = content + g.Nodes[i].Metadata = metadata + g.Nodes[i].UpdatedAt = now + g.Nodes[i].AccessCount++ + node := g.Nodes[i] + if err := save(rootDir, g); err != nil { + return nil, err + } + return &node, nil + } + } + + node := Node{ + ID: id, + Type: t, + Label: label, + Content: content, + Metadata: metadata, + CreatedAt: now, + UpdatedAt: now, + } + g.Nodes = append(g.Nodes, node) + if err := save(rootDir, g); err != nil { + return nil, err + } + return &node, nil +} + +// CreateRelation adds a directed edge between two existing nodes. +// Returns nil if either node ID is not found. +func CreateRelation(rootDir, sourceID, targetID string, relation RelationType, weight float64, metadata map[string]string) (*Edge, error) { + mu.Lock() + defer mu.Unlock() + + g, err := loadLocked(rootDir) + if err != nil { + return nil, err + } + + if !nodeExists(g, sourceID) || !nodeExists(g, targetID) { + return nil, nil //nolint:nilnil // caller checks nil to detect missing nodes + } + + if weight <= 0 { + weight = 1.0 + } + + id := edgeID(sourceID, targetID, relation) + + // Upsert: update weight if edge already exists. + for i := range g.Edges { + if g.Edges[i].ID == id { + g.Edges[i].Weight = weight + g.Edges[i].Metadata = metadata + edge := g.Edges[i] + if err := save(rootDir, g); err != nil { + return nil, err + } + return &edge, nil + } + } + + edge := Edge{ + ID: id, + Source: sourceID, + Target: targetID, + Relation: relation, + Weight: weight, + Metadata: metadata, + } + g.Edges = append(g.Edges, edge) + if err := save(rootDir, g); err != nil { + return nil, err + } + return &edge, nil +} + +// GetGraphStats returns a snapshot of node and edge counts. +func GetGraphStats(rootDir string) (GraphStats, error) { + g, err := load(rootDir) + if err != nil { + return GraphStats{}, err + } + return GraphStats{Nodes: len(g.Nodes), Edges: len(g.Edges)}, nil +} + +// PruneResult reports what was removed during a prune pass. +type PruneResult struct { + Removed int + Remaining int +} + +// PruneStaleLinks removes edges whose weight falls below threshold and then +// removes any nodes that have become fully orphaned (no edges in or out). +func PruneStaleLinks(rootDir string, threshold float64) (PruneResult, error) { + if threshold <= 0 { + threshold = 0.1 + } + + mu.Lock() + defer mu.Unlock() + + g, err := loadLocked(rootDir) + if err != nil { + return PruneResult{}, err + } + + removed := 0 + + // Remove weak edges. + live := g.Edges[:0] + for _, e := range g.Edges { + if e.Weight >= threshold { + live = append(live, e) + } else { + removed++ + } + } + g.Edges = live + + // Remove orphaned nodes (no remaining edges reference them). + connected := make(map[string]bool, len(g.Nodes)) + for _, e := range g.Edges { + connected[e.Source] = true + connected[e.Target] = true + } + liveNodes := g.Nodes[:0] + for _, n := range g.Nodes { + if connected[n.ID] { + liveNodes = append(liveNodes, n) + } else { + removed++ + } + } + g.Nodes = liveNodes + +< truncated lines 316-378 > +} + +// SearchGraph finds nodes whose label or content matches query, then expands +// one hop to collect linked neighbors. Results are scored by relevance. +func SearchGraph(rootDir, query string, maxDepth, topK int, edgeFilter []RelationType) (*SearchResult, error) { + if maxDepth <= 0 { + maxDepth = 2 + } + if topK <= 0 { + topK = 10 + } + + g, err := load(rootDir) + if err != nil { + return nil, err + } + + queryLower := strings.ToLower(query) + nodeByID := indexNodes(g) + adjOut := buildAdjacency(g, edgeFilter) // nodeID → []Edge + + // Score all nodes against the query. + type scored struct { + node Node + score float64 + } + var candidates []scored + for _, n := range g.Nodes { + score := scoreNode(n, queryLower) + if score > 0 { + candidates = append(candidates, scored{node: n, score: score}) + } + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].score > candidates[j].score + }) + if len(candidates) > topK { + candidates = candidates[:topK] + } + + result := &SearchResult{ + TotalNodes: len(g.Nodes), + TotalEdges: len(g.Edges), + } + directIDs := make(map[string]bool) + for _, c := range candidates { + result.Direct = append(result.Direct, TraversalResult{ + Node: c.node, + Depth: 0, + RelevanceScore: c.score, + PathRelations: []string{}, + }) + directIDs[c.node.ID] = true + } + + // Expand one hop of neighbors. + neighborIDs := make(map[string]bool) + for _, c := range candidates { + for _, edge := range adjOut[c.node.ID] { + if directIDs[edge.Target] || neighborIDs[edge.Target] { + continue + } + if n, ok := nodeByID[edge.Target]; ok { + neighborIDs[edge.Target] = true + score := scoreNode(n, queryLower) * edge.Weight * 0.5 + result.Neighbors = append(result.Neighbors, TraversalResult{ + Node: n, + Depth: 1, + RelevanceScore: score, + PathRelations: []string{string(edge.Relation)}, + }) + } + } + } + sort.Slice(result.Neighbors, func(i, j int) bool { + return result.Neighbors[i].RelevanceScore > result.Neighbors[j].RelevanceScore + }) + + // Bump access counts for returned nodes. + go func() { _ = bumpAccess(rootDir, directIDs) }() + + return result, nil +} + +// RetrieveWithTraversal performs a BFS/depth-limited walk starting from +// startNodeID, returning all reachable nodes up to maxDepth hops away. +// edgeFilter restricts which relation types are followed; nil follows all. +func RetrieveWithTraversal(rootDir, startNodeID string, maxDepth int, edgeFilter []RelationType) ([]TraversalResult, error) { + if maxDepth <= 0 { + maxDepth = 2 + } + + g, err := load(rootDir) + if err != nil { + return nil, err + } + + nodeByID := indexNodes(g) + startNode, ok := nodeByID[startNodeID] + if !ok { + return nil, nil + } + + adjOut := buildAdjacency(g, edgeFilter) + + type queueItem struct { + nodeID string + depth int + pathRelations []string + score float64 + } + + visited := map[string]bool{startNodeID: true} + queue := []queueItem{{nodeID: startNodeID, depth: 0, pathRelations: []string{}, score: 1.0}} + var results []TraversalResult + + results = append(results, TraversalResult{ + Node: startNode, + Depth: 0, + RelevanceScore: 1.0, + PathRelations: []string{}, + }) + + for len(queue) > 0 { + item := queue[0] + queue = queue[1:] + + if item.depth >= maxDepth { + continue + } + + for _, edge := range adjOut[item.nodeID] { + if visited[edge.Target] { + continue + } + visited[edge.Target] = true + + n, ok := nodeByID[edge.Target] + if !ok { + continue + } + + // Decay relevance with depth and edge weight. + score := item.score * edge.Weight * math.Pow(0.8, float64(item.depth+1)) + pathRels := append(append([]string(nil), item.pathRelations...), string(edge.Relation)) + + results = append(results, TraversalResult{ + Node: n, + Depth: item.depth + 1, + RelevanceScore: score, + PathRelations: pathRels, + }) + queue = append(queue, queueItem{ + nodeID: edge.Target, + depth: item.depth + 1, + pathRelations: pathRels, + score: score, + }) + } + } + + // Sort by depth first, then descending relevance. + sort.Slice(results, func(i, j int) bool { + if results[i].Depth != results[j].Depth { + return results[i].Depth < results[j].Depth + } + return results[i].RelevanceScore > results[j].RelevanceScore + }) + + go func() { + ids := make(map[string]bool, len(results)) + for _, r := range results { + ids[r.Node.ID] = true + } + _ = bumpAccess(rootDir, ids) + }() + + return results, nil +} + +// --- Internal helpers -------------------------------------------------------- + +// loadLocked loads the graph assuming the caller already holds mu (write lock). +// It reads directly from the in-memory cache or from disk without acquiring +// any additional locks (caller already holds the write lock). +func loadLocked(rootDir string) (*graphData, error) { + if g, ok := cache[rootDir]; ok { + return g, nil + } + path := graphPath(rootDir) + g := &graphData{} + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("memorygraph: read %s: %w", path, err) + } + if err == nil { + if err := json.Unmarshal(data, g); err != nil { + return nil, fmt.Errorf("memorygraph: parse %s: %w", path, err) + } + } + cache[rootDir] = g + return g, nil +} + +func nodeExists(g *graphData, id string) bool { + for _, n := range g.Nodes { + if n.ID == id { + return true + } + } + return false +} + +func indexNodes(g *graphData) map[string]Node { + m := make(map[string]Node, len(g.Nodes)) + for _, n := range g.Nodes { + m[n.ID] = n + } + return m +} + +// buildAdjacency returns a map of nodeID → outgoing edges, optionally filtered +// by relation type. +func buildAdjacency(g *graphData, filter []RelationType) map[string][]Edge { + allowed := make(map[RelationType]bool, len(filter)) + for _, r := range filter { + allowed[r] = true + } + + adj := make(map[string][]Edge) + for _, e := range g.Edges { + if len(filter) > 0 && !allowed[e.Relation] { + continue + } + adj[e.Source] = append(adj[e.Source], e) + } + return adj +} + +// scoreNode scores a node against a lower-cased query string. +// Label matches are weighted more heavily than content matches. +func scoreNode(n Node, queryLower string) float64 { + labelLower := strings.ToLower(n.Label) + contentLower := strings.ToLower(n.Content) + + var score float64 + if strings.Contains(labelLower, queryLower) { + score += 1.0 + } + if strings.Contains(contentLower, queryLower) { + // Partial overlap: proportion of query tokens found in content. + score += tokenOverlap(queryLower, contentLower) * 0.6 + } + // Popularity bias: nodes accessed frequently are slightly preferred. + if n.AccessCount > 0 { + score += math.Log1p(float64(n.AccessCount)) * 0.05 + } + return score +} + +// tokenOverlap returns the fraction of query tokens present in text. +func tokenOverlap(query, text string) float64 { + queryTokens := strings.Fields(query) + if len(queryTokens) == 0 { + return 0 + } + found := 0 + for _, t := range queryTokens { + if strings.Contains(text, t) { + found++ + } + } + return float64(found) / float64(len(queryTokens)) +} + +// jaccardSimilarity approximates cosine similarity via token-set Jaccard. +func jaccardSimilarity(a, b string) float64 { + ta := tokenSet(a) + tb := tokenSet(b) + if len(ta) == 0 || len(tb) == 0 { + return 0 + } + intersection := 0 + for t := range ta { + if tb[t] { + intersection++ + } + } + return float64(intersection) / float64(len(ta)+len(tb)-intersection) +} + +func tokenSet(s string) map[string]bool { + m := make(map[string]bool) + for _, t := range strings.Fields(strings.ToLower(s)) { + m[t] = true + } + return m +} + +// bumpAccess increments the AccessCount for each node in ids and persists. +func bumpAccess(rootDir string, ids map[string]bool) error { + mu.Lock() + defer mu.Unlock() + + g, ok := cache[rootDir] + if !ok { + return nil + } + for i := range g.Nodes { + if ids[g.Nodes[i].ID] { + g.Nodes[i].AccessCount++ + } + } + return save(rootDir, g) +} From 647451d77c6eed39f90e39900752c95beab19316 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 08:58:41 -0400 Subject: [PATCH 13/32] Create tools.go All three go files into internal/Memorygraph @coderabbitai --- internal/Memorygraph/tools.go | 245 ++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 internal/Memorygraph/tools.go diff --git a/internal/Memorygraph/tools.go b/internal/Memorygraph/tools.go new file mode 100644 index 0000000..9185945 --- /dev/null +++ b/internal/Memorygraph/tools.go @@ -0,0 +1,245 @@ +// Package memorygraph — MCP tool wrappers. +// Each exported Tool* function is the Go equivalent of the TypeScript tool +// functions in tools/memory-tools.ts and follows the same output format so +// that callers get identical text responses. +package memorygraph + +import ( + "fmt" + "strings" +) + +// --- Tool option structs ------------------------------------------------------ + +// UpsertMemoryNodeOptions mirrors UpsertMemoryNodeOptions in the TS source. +type UpsertMemoryNodeOptions struct { + RootDir string + Type NodeType + Label string + Content string + Metadata map[string]string +} + +// CreateRelationOptions mirrors CreateRelationOptions in the TS source. +type CreateRelationOptions struct { + RootDir string + SourceID string + TargetID string + Relation RelationType + Weight float64 + Metadata map[string]string +} + +// SearchMemoryGraphOptions mirrors SearchMemoryGraphOptions in the TS source. +type SearchMemoryGraphOptions struct { + RootDir string + Query string + MaxDepth int + TopK int + EdgeFilter []RelationType +} + +// PruneStaleLinksOptions mirrors PruneStaleLinksOptions in the TS source. +type PruneStaleLinksOptions struct { + RootDir string + Threshold float64 +} + +// InterlinkedItem is a single entry for AddInterlinkedContext. +type InterlinkedItem struct { + Type NodeType + Label string + Content string + Metadata map[string]string +} + +// AddInterlinkedContextOptions mirrors AddInterlinkedContextOptions in the TS source. +type AddInterlinkedContextOptions struct { + RootDir string + Items []InterlinkedItem + AutoLink bool +} + +// RetrieveWithTraversalOptions mirrors RetrieveWithTraversalOptions in the TS source. +type RetrieveWithTraversalOptions struct { + RootDir string + StartNodeID string + MaxDepth int + EdgeFilter []RelationType +} + +// --- Formatters -------------------------------------------------------------- + +func formatTraversalResult(r TraversalResult) string { + content := r.Node.Content + if len(content) > 120 { + content = content[:120] + "..." + } + lines := []string{ + fmt.Sprintf(" [%s] %s (depth: %d, score: %.2f)", r.Node.Type, r.Node.Label, r.Depth, r.RelevanceScore), + fmt.Sprintf(" Content: %s", content), + } + if len(r.PathRelations) > 1 { + lines = append(lines, fmt.Sprintf(" Path: %s", strings.Join(r.PathRelations, " "))) + } + lines = append(lines, fmt.Sprintf(" ID: %s | Accessed: %dx", r.Node.ID, r.Node.AccessCount)) + return strings.Join(lines, "\n") +} + +// --- Tool implementations ---------------------------------------------------- + +// ToolUpsertMemoryNode creates or updates a memory node and returns a +// human-readable summary including updated graph stats. +func ToolUpsertMemoryNode(opts UpsertMemoryNodeOptions) (string, error) { + node, err := UpsertNode(opts.RootDir, opts.Type, opts.Label, opts.Content, opts.Metadata) + if err != nil { + return "", err + } + stats, err := GetGraphStats(opts.RootDir) + if err != nil { + return "", err + } + return strings.Join([]string{ + fmt.Sprintf("✅ Memory node upserted: %s", node.Label), + fmt.Sprintf(" ID: %s", node.ID), + fmt.Sprintf(" Type: %s", node.Type), + fmt.Sprintf(" Access count: %d", node.AccessCount), + fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), + }, "\n"), nil +} + +// ToolCreateRelation adds a directed edge between two existing nodes. +func ToolCreateRelation(opts CreateRelationOptions) (string, error) { + edge, err := CreateRelation(opts.RootDir, opts.SourceID, opts.TargetID, opts.Relation, opts.Weight, opts.Metadata) + if err != nil { + return "", err + } + if edge == nil { + return fmt.Sprintf("❌ Failed: one or both node IDs not found (source: %s, target: %s)", + opts.SourceID, opts.TargetID), nil + } + stats, err := GetGraphStats(opts.RootDir) + if err != nil { + return "", err + } + return strings.Join([]string{ + fmt.Sprintf("✅ Relation created: %s --[%s]--> %s", opts.SourceID, edge.Relation, opts.TargetID), + fmt.Sprintf(" Edge ID: %s", edge.ID), + fmt.Sprintf(" Weight: %.2f", edge.Weight), + fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), + }, "\n"), nil +} + +// ToolSearchMemoryGraph searches the graph and returns direct matches plus +// one-hop neighbors, formatted identically to the TypeScript version. +func ToolSearchMemoryGraph(opts SearchMemoryGraphOptions) (string, error) { + result, err := SearchGraph(opts.RootDir, opts.Query, opts.MaxDepth, opts.TopK, opts.EdgeFilter) + if err != nil { + return "", err + } + if len(result.Direct) == 0 { + return fmt.Sprintf("No memory nodes found for: %q\nGraph has %d nodes, %d edges.", + opts.Query, result.TotalNodes, result.TotalEdges), nil + } + + sections := []string{ + fmt.Sprintf("Memory Graph Search: %q", opts.Query), + fmt.Sprintf("Graph: %d nodes, %d edges\n", result.TotalNodes, result.TotalEdges), + "Direct Matches:", + } + for _, hit := range result.Direct { + sections = append(sections, formatTraversalResult(hit)) + } + if len(result.Neighbors) > 0 { + sections = append(sections, "\nLinked Neighbors:") + for _, neighbor := range result.Neighbors { + sections = append(sections, formatTraversalResult(neighbor)) + } + } + return strings.Join(sections, "\n"), nil +} + +// ToolPruneStaleLinks removes weak edges and orphaned nodes. +func ToolPruneStaleLinks(opts PruneStaleLinksOptions) (string, error) { + result, err := PruneStaleLinks(opts.RootDir, opts.Threshold) + if err != nil { + return "", err + } + return strings.Join([]string{ + "🧹 Pruning complete", + fmt.Sprintf(" Removed: %d stale links/orphan nodes", result.Removed), + fmt.Sprintf(" Remaining edges: %d", result.Remaining), + }, "\n"), nil +} + +// ToolAddInterlinkedContext bulk-upserts nodes and optionally auto-links them +// by content similarity (threshold ≥ 0.72). +func ToolAddInterlinkedContext(opts AddInterlinkedContextOptions) (string, error) { + items := make([]struct { + Type NodeType + Label string + Content string + Metadata map[string]string + }, len(opts.Items)) + for i, it := range opts.Items { + items[i].Type = it.Type + items[i].Label = it.Label + items[i].Content = it.Content + items[i].Metadata = it.Metadata + } + + result, err := AddInterlinkedContext(opts.RootDir, items, opts.AutoLink) + if err != nil { + return "", err + } + + sections := []string{fmt.Sprintf("✅ Added %d interlinked nodes", len(result.Nodes))} + if len(result.Edges) > 0 { + sections = append(sections, fmt.Sprintf(" Auto-linked: %d similarity edges (threshold ≥ 0.72)", len(result.Edges))) + } else { + sections = append(sections, " No auto-links above threshold") + } + sections = append(sections, "\nNodes:") + for _, n := range result.Nodes { + sections = append(sections, fmt.Sprintf(" [%s] %s → %s", n.Type, n.Label, n.ID)) + } + if len(result.Edges) > 0 { + sections = append(sections, "\nEdges:") + for _, e := range result.Edges { + sections = append(sections, fmt.Sprintf(" %s --[%s w:%.2f]--> %s", + e.Source, e.Relation, e.Weight, e.Target)) + } + } + + stats, err := GetGraphStats(opts.RootDir) + if err != nil { + return "", err + } + sections = append(sections, fmt.Sprintf("\nGraph total: %d nodes, %d edges", stats.Nodes, stats.Edges)) + return strings.Join(sections, "\n"), nil +} + +// ToolRetrieveWithTraversal starts a BFS from startNodeID and returns all +// reachable nodes up to maxDepth, formatted with path context and scores. +func ToolRetrieveWithTraversal(opts RetrieveWithTraversalOptions) (string, error) { + results, err := RetrieveWithTraversal(opts.RootDir, opts.StartNodeID, opts.MaxDepth, opts.EdgeFilter) + if err != nil { + return "", err + } + if len(results) == 0 { + return fmt.Sprintf("❌ Node not found: %s", opts.StartNodeID), nil + } + + maxDepth := opts.MaxDepth + if maxDepth <= 0 { + maxDepth = 2 + } + + sections := []string{ + fmt.Sprintf("Traversal from: %s (depth limit: %d)\n", results[0].Node.Label, maxDepth), + } + for _, r := range results { + sections = append(sections, formatTraversalResult(r)) + } + return strings.Join(sections, "\n"), nil +} From c35e3706a7f15d3c2935dfddd652ac666a064e81 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 09:11:52 -0400 Subject: [PATCH 14/32] Update Memorygraph.go --- internal/Memorygraph/Memorygraph.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/Memorygraph/Memorygraph.go b/internal/Memorygraph/Memorygraph.go index 30de28c..c494c91 100644 --- a/internal/Memorygraph/Memorygraph.go +++ b/internal/Memorygraph/Memorygraph.go @@ -336,9 +336,17 @@ func PruneStaleLinks(rootDir string, threshold float64) (PruneResult, error) { < truncated lines 316-378 > } + // ... rest of the function unchanged // SearchGraph finds nodes whose label or content matches query, then expands // one hop to collect linked neighbors. Results are scored by relevance. func SearchGraph(rootDir, query string, maxDepth, topK int, edgeFilter []RelationType) (*SearchResult, error) { + if strings.TrimSpace(query) == "" { + g, err := load(rootDir) + if err != nil { + return nil, err + } + return &SearchResult{TotalNodes: len(g.Nodes), TotalEdges: len(g.Edges)}, nil + } if maxDepth <= 0 { maxDepth = 2 } From 8a8c24a733bd9c59ef3f9b6df5da092f55b28de3 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 09:26:11 -0400 Subject: [PATCH 15/32] Add files via upload --- internal/Memorygraph/graph.go | 693 ++++++++++++++++++++++++++++++++++ 1 file changed, 693 insertions(+) create mode 100644 internal/Memorygraph/graph.go diff --git a/internal/Memorygraph/graph.go b/internal/Memorygraph/graph.go new file mode 100644 index 0000000..8bee0fa --- /dev/null +++ b/internal/Memorygraph/graph.go @@ -0,0 +1,693 @@ +// Package memorygraph implements a persistent memory graph for interlinked RAG. +// Nodes represent typed knowledge units; edges are weighted, typed relations. +// The graph is stored as a single JSON file under rootDir/.supermodel/memory-graph.json +// and is safe for concurrent reads within a process (writes hold a mutex). +package memorygraph + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// --- Types ------------------------------------------------------------------- + +// NodeType classifies what kind of knowledge a node represents. +type NodeType string + +const ( + NodeTypeFact NodeType = "fact" + NodeTypeConcept NodeType = "concept" + NodeTypeEntity NodeType = "entity" + NodeTypeEvent NodeType = "event" + NodeTypeProcedure NodeType = "procedure" + NodeTypeContext NodeType = "context" +) + +// RelationType classifies the semantic relationship between two nodes. +type RelationType string + +const ( + RelationRelatedTo RelationType = "related_to" + RelationDependsOn RelationType = "depends_on" + RelationPartOf RelationType = "part_of" + RelationLeadsTo RelationType = "leads_to" + RelationContrasts RelationType = "contrasts" + RelationSimilarTo RelationType = "similar_to" + RelationInstantiates RelationType = "instantiates" +) + +// Node is a single knowledge unit in the memory graph. +type Node struct { + ID string `json:"id"` + Type NodeType `json:"type"` + Label string `json:"label"` + Content string `json:"content"` + Metadata map[string]string `json:"metadata,omitempty"` + AccessCount int `json:"accessCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Edge is a directed, weighted relation between two nodes. +type Edge struct { + ID string `json:"id"` + Source string `json:"source"` + Target string `json:"target"` + Relation RelationType `json:"relation"` + Weight float64 `json:"weight"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// TraversalResult is a node reached during graph traversal, enriched with +// path context and a relevance score. +type TraversalResult struct { + Node Node + Depth int + RelevanceScore float64 + PathRelations []string // relation labels along the path from the start node +} + +// GraphStats summarises the current state of the graph. +type GraphStats struct { + Nodes int + Edges int +} + +// graphData is the on-disk format. +type graphData struct { + Nodes []Node `json:"nodes"` + Edges []Edge `json:"edges"` +} + +// --- Storage ----------------------------------------------------------------- + +const graphFile = ".supermodel/memory-graph.json" + +var ( + mu sync.RWMutex + cache = map[string]*graphData{} // rootDir → loaded graph +) + +func graphPath(rootDir string) string { + return filepath.Join(rootDir, graphFile) +} + +// load reads the graph for rootDir from disk (or returns the in-memory cache). +func load(rootDir string) (*graphData, error) { + mu.RLock() + if g, ok := cache[rootDir]; ok { + mu.RUnlock() + return g, nil + } + mu.RUnlock() + + mu.Lock() + defer mu.Unlock() + + // Double-checked locking. + if g, ok := cache[rootDir]; ok { + return g, nil + } + + path := graphPath(rootDir) + g := &graphData{} + + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("memorygraph: read %s: %w", path, err) + } + if err == nil { + if err := json.Unmarshal(data, g); err != nil { + return nil, fmt.Errorf("memorygraph: parse %s: %w", path, err) + } + } + + cache[rootDir] = g + return g, nil +} + +// save persists g to disk. Caller must hold mu (write lock). +func save(rootDir string, g *graphData) error { + path := graphPath(rootDir) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("memorygraph: mkdir: %w", err) + } + data, err := json.MarshalIndent(g, "", " ") + if err != nil { + return fmt.Errorf("memorygraph: marshal: %w", err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("memorygraph: write: %w", err) + } + return nil +} + +// nodeID derives a stable deterministic ID from type+label. +func nodeID(t NodeType, label string) string { + return fmt.Sprintf("%s:%s", t, strings.ToLower(strings.ReplaceAll(label, " ", "_"))) +} + +// edgeID derives a stable deterministic ID from endpoints+relation. +func edgeID(source, target string, relation RelationType) string { + return fmt.Sprintf("%s--%s-->%s", source, relation, target) +} + +// --- Core operations --------------------------------------------------------- + +// UpsertNode creates or updates a node identified by (type, label). +// If the node already exists its content and metadata are updated in-place. +func UpsertNode(rootDir string, t NodeType, label, content string, metadata map[string]string) (*Node, error) { + mu.Lock() + defer mu.Unlock() + + g, err := loadLocked(rootDir) + if err != nil { + return nil, err + } + + id := nodeID(t, label) + now := time.Now().UTC() + + for i := range g.Nodes { + if g.Nodes[i].ID == id { + g.Nodes[i].Content = content + g.Nodes[i].Metadata = metadata + g.Nodes[i].UpdatedAt = now + g.Nodes[i].AccessCount++ + node := g.Nodes[i] + if err := save(rootDir, g); err != nil { + return nil, err + } + return &node, nil + } + } + + node := Node{ + ID: id, + Type: t, + Label: label, + Content: content, + Metadata: metadata, + CreatedAt: now, + UpdatedAt: now, + } + g.Nodes = append(g.Nodes, node) + if err := save(rootDir, g); err != nil { + return nil, err + } + return &node, nil +} + +// CreateRelation adds a directed edge between two existing nodes. +// Returns nil if either node ID is not found. +func CreateRelation(rootDir, sourceID, targetID string, relation RelationType, weight float64, metadata map[string]string) (*Edge, error) { + mu.Lock() + defer mu.Unlock() + + g, err := loadLocked(rootDir) + if err != nil { + return nil, err + } + + if !nodeExists(g, sourceID) || !nodeExists(g, targetID) { + return nil, nil //nolint:nilnil // caller checks nil to detect missing nodes + } + + if weight <= 0 { + weight = 1.0 + } + + id := edgeID(sourceID, targetID, relation) + + // Upsert: update weight if edge already exists. + for i := range g.Edges { + if g.Edges[i].ID == id { + g.Edges[i].Weight = weight + g.Edges[i].Metadata = metadata + edge := g.Edges[i] + if err := save(rootDir, g); err != nil { + return nil, err + } + return &edge, nil + } + } + + edge := Edge{ + ID: id, + Source: sourceID, + Target: targetID, + Relation: relation, + Weight: weight, + Metadata: metadata, + } + g.Edges = append(g.Edges, edge) + if err := save(rootDir, g); err != nil { + return nil, err + } + return &edge, nil +} + +// GetGraphStats returns a snapshot of node and edge counts. +func GetGraphStats(rootDir string) (GraphStats, error) { + g, err := load(rootDir) + if err != nil { + return GraphStats{}, err + } + return GraphStats{Nodes: len(g.Nodes), Edges: len(g.Edges)}, nil +} + +// PruneResult reports what was removed during a prune pass. +type PruneResult struct { + Removed int + Remaining int +} + +// PruneStaleLinks removes edges whose weight falls below threshold and then +// removes any nodes that have become fully orphaned (no edges in or out). +func PruneStaleLinks(rootDir string, threshold float64) (PruneResult, error) { + if threshold <= 0 { + threshold = 0.1 + } + + mu.Lock() + defer mu.Unlock() + + g, err := loadLocked(rootDir) + if err != nil { + return PruneResult{}, err + } + + removed := 0 + + // Remove weak edges. + live := g.Edges[:0] + for _, e := range g.Edges { + if e.Weight >= threshold { + live = append(live, e) + } else { + removed++ + } + } + g.Edges = live + + // Remove orphaned nodes (no remaining edges reference them). + connected := make(map[string]bool, len(g.Nodes)) + for _, e := range g.Edges { + connected[e.Source] = true + connected[e.Target] = true + } + liveNodes := g.Nodes[:0] + for _, n := range g.Nodes { + if connected[n.ID] { + liveNodes = append(liveNodes, n) + } else { + removed++ + } + } + g.Nodes = liveNodes + + if err := save(rootDir, g); err != nil { + return PruneResult{}, err + } + return PruneResult{Removed: removed, Remaining: len(g.Edges)}, nil +} + +// InterlinkResult is returned by AddInterlinkedContext. +type InterlinkResult struct { + Nodes []Node + Edges []Edge +} + +// AddInterlinkedContext bulk-upserts a set of nodes and, when autoLink is true, +// automatically creates similarity edges between pairs whose content overlaps +// above a fixed threshold (0.72 cosine-approximated via token Jaccard). +func AddInterlinkedContext(rootDir string, items []struct { + Type NodeType + Label string + Content string + Metadata map[string]string +}, autoLink bool) (*InterlinkResult, error) { + result := &InterlinkResult{} + + for _, item := range items { + n, err := UpsertNode(rootDir, item.Type, item.Label, item.Content, item.Metadata) + if err != nil { + return nil, err + } + result.Nodes = append(result.Nodes, *n) + } + + if !autoLink || len(result.Nodes) < 2 { + return result, nil + } + + const similarityThreshold = 0.72 + for i := 0; i < len(result.Nodes); i++ { + for j := i + 1; j < len(result.Nodes); j++ { + sim := jaccardSimilarity(result.Nodes[i].Content, result.Nodes[j].Content) + if sim >= similarityThreshold { + edge, err := CreateRelation(rootDir, + result.Nodes[i].ID, result.Nodes[j].ID, + RelationSimilarTo, sim, nil) + if err != nil { + return nil, err + } + if edge != nil { + result.Edges = append(result.Edges, *edge) + } + } + } + } + return result, nil +} + +// --- Traversal --------------------------------------------------------------- + +// SearchResult is returned by SearchGraph. +type SearchResult struct { + Direct []TraversalResult + Neighbors []TraversalResult + TotalNodes int + TotalEdges int +} + +// SearchGraph finds nodes whose label or content matches query, then expands +// one hop to collect linked neighbors. Results are scored by relevance. +func SearchGraph(rootDir, query string, maxDepth, topK int, edgeFilter []RelationType) (*SearchResult, error) { + if maxDepth <= 0 { + maxDepth = 2 + } + if topK <= 0 { + topK = 10 + } + + g, err := load(rootDir) + if err != nil { + return nil, err + } + + queryLower := strings.ToLower(query) + nodeByID := indexNodes(g) + adjOut := buildAdjacency(g, edgeFilter) // nodeID → []Edge + + // Score all nodes against the query. + type scored struct { + node Node + score float64 + } + var candidates []scored + for _, n := range g.Nodes { + score := scoreNode(n, queryLower) + if score > 0 { + candidates = append(candidates, scored{node: n, score: score}) + } + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].score > candidates[j].score + }) + if len(candidates) > topK { + candidates = candidates[:topK] + } + + result := &SearchResult{ + TotalNodes: len(g.Nodes), + TotalEdges: len(g.Edges), + } + directIDs := make(map[string]bool) + for _, c := range candidates { + result.Direct = append(result.Direct, TraversalResult{ + Node: c.node, + Depth: 0, + RelevanceScore: c.score, + PathRelations: []string{}, + }) + directIDs[c.node.ID] = true + } + + // Expand one hop of neighbors. + neighborIDs := make(map[string]bool) + for _, c := range candidates { + for _, edge := range adjOut[c.node.ID] { + if directIDs[edge.Target] || neighborIDs[edge.Target] { + continue + } + if n, ok := nodeByID[edge.Target]; ok { + neighborIDs[edge.Target] = true + score := scoreNode(n, queryLower) * edge.Weight * 0.5 + result.Neighbors = append(result.Neighbors, TraversalResult{ + Node: n, + Depth: 1, + RelevanceScore: score, + PathRelations: []string{string(edge.Relation)}, + }) + } + } + } + sort.Slice(result.Neighbors, func(i, j int) bool { + return result.Neighbors[i].RelevanceScore > result.Neighbors[j].RelevanceScore + }) + + // Bump access counts for returned nodes. + go func() { _ = bumpAccess(rootDir, directIDs) }() + + return result, nil +} + +// RetrieveWithTraversal performs a BFS/depth-limited walk starting from +// startNodeID, returning all reachable nodes up to maxDepth hops away. +// edgeFilter restricts which relation types are followed; nil follows all. +func RetrieveWithTraversal(rootDir, startNodeID string, maxDepth int, edgeFilter []RelationType) ([]TraversalResult, error) { + if maxDepth <= 0 { + maxDepth = 2 + } + + g, err := load(rootDir) + if err != nil { + return nil, err + } + + nodeByID := indexNodes(g) + startNode, ok := nodeByID[startNodeID] + if !ok { + return nil, nil + } + + adjOut := buildAdjacency(g, edgeFilter) + + type queueItem struct { + nodeID string + depth int + pathRelations []string + score float64 + } + + visited := map[string]bool{startNodeID: true} + queue := []queueItem{{nodeID: startNodeID, depth: 0, pathRelations: []string{}, score: 1.0}} + var results []TraversalResult + + results = append(results, TraversalResult{ + Node: startNode, + Depth: 0, + RelevanceScore: 1.0, + PathRelations: []string{}, + }) + + for len(queue) > 0 { + item := queue[0] + queue = queue[1:] + + if item.depth >= maxDepth { + continue + } + + for _, edge := range adjOut[item.nodeID] { + if visited[edge.Target] { + continue + } + visited[edge.Target] = true + + n, ok := nodeByID[edge.Target] + if !ok { + continue + } + + // Decay relevance with depth and edge weight. + score := item.score * edge.Weight * math.Pow(0.8, float64(item.depth+1)) + pathRels := append(append([]string(nil), item.pathRelations...), string(edge.Relation)) + + results = append(results, TraversalResult{ + Node: n, + Depth: item.depth + 1, + RelevanceScore: score, + PathRelations: pathRels, + }) + queue = append(queue, queueItem{ + nodeID: edge.Target, + depth: item.depth + 1, + pathRelations: pathRels, + score: score, + }) + } + } + + // Sort by depth first, then descending relevance. + sort.Slice(results, func(i, j int) bool { + if results[i].Depth != results[j].Depth { + return results[i].Depth < results[j].Depth + } + return results[i].RelevanceScore > results[j].RelevanceScore + }) + + go func() { + ids := make(map[string]bool, len(results)) + for _, r := range results { + ids[r.Node.ID] = true + } + _ = bumpAccess(rootDir, ids) + }() + + return results, nil +} + +// --- Internal helpers -------------------------------------------------------- + +// loadLocked loads the graph assuming the caller already holds mu (write lock). +// It reads directly from the in-memory cache or from disk without acquiring +// any additional locks (caller already holds the write lock). +func loadLocked(rootDir string) (*graphData, error) { + if g, ok := cache[rootDir]; ok { + return g, nil + } + path := graphPath(rootDir) + g := &graphData{} + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("memorygraph: read %s: %w", path, err) + } + if err == nil { + if err := json.Unmarshal(data, g); err != nil { + return nil, fmt.Errorf("memorygraph: parse %s: %w", path, err) + } + } + cache[rootDir] = g + return g, nil +} + +func nodeExists(g *graphData, id string) bool { + for _, n := range g.Nodes { + if n.ID == id { + return true + } + } + return false +} + +func indexNodes(g *graphData) map[string]Node { + m := make(map[string]Node, len(g.Nodes)) + for _, n := range g.Nodes { + m[n.ID] = n + } + return m +} + +// buildAdjacency returns a map of nodeID → outgoing edges, optionally filtered +// by relation type. +func buildAdjacency(g *graphData, filter []RelationType) map[string][]Edge { + allowed := make(map[RelationType]bool, len(filter)) + for _, r := range filter { + allowed[r] = true + } + + adj := make(map[string][]Edge) + for _, e := range g.Edges { + if len(filter) > 0 && !allowed[e.Relation] { + continue + } + adj[e.Source] = append(adj[e.Source], e) + } + return adj +} + +// scoreNode scores a node against a lower-cased query string. +// Label matches are weighted more heavily than content matches. +func scoreNode(n Node, queryLower string) float64 { + labelLower := strings.ToLower(n.Label) + contentLower := strings.ToLower(n.Content) + + var score float64 + if strings.Contains(labelLower, queryLower) { + score += 1.0 + } + if strings.Contains(contentLower, queryLower) { + // Partial overlap: proportion of query tokens found in content. + score += tokenOverlap(queryLower, contentLower) * 0.6 + } + // Popularity bias: nodes accessed frequently are slightly preferred. + if n.AccessCount > 0 { + score += math.Log1p(float64(n.AccessCount)) * 0.05 + } + return score +} + +// tokenOverlap returns the fraction of query tokens present in text. +func tokenOverlap(query, text string) float64 { + queryTokens := strings.Fields(query) + if len(queryTokens) == 0 { + return 0 + } + found := 0 + for _, t := range queryTokens { + if strings.Contains(text, t) { + found++ + } + } + return float64(found) / float64(len(queryTokens)) +} + +// jaccardSimilarity approximates cosine similarity via token-set Jaccard. +func jaccardSimilarity(a, b string) float64 { + ta := tokenSet(a) + tb := tokenSet(b) + if len(ta) == 0 || len(tb) == 0 { + return 0 + } + intersection := 0 + for t := range ta { + if tb[t] { + intersection++ + } + } + return float64(intersection) / float64(len(ta)+len(tb)-intersection) +} + +func tokenSet(s string) map[string]bool { + m := make(map[string]bool) + for _, t := range strings.Fields(strings.ToLower(s)) { + m[t] = true + } + return m +} + +// bumpAccess increments the AccessCount for each node in ids and persists. +func bumpAccess(rootDir string, ids map[string]bool) error { + mu.Lock() + defer mu.Unlock() + + g, ok := cache[rootDir] + if !ok { + return nil + } + for i := range g.Nodes { + if ids[g.Nodes[i].ID] { + g.Nodes[i].AccessCount++ + } + } + return save(rootDir, g) +} From ca5f18e3f22485bdc15351fa9d3936d02d1efbbd Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 09:30:08 -0400 Subject: [PATCH 16/32] Update and rename graph.go to memorygraph.go --- internal/Memorygraph/{graph.go => memorygraph.go} | 7 +++++++ 1 file changed, 7 insertions(+) rename internal/Memorygraph/{graph.go => memorygraph.go} (98%) diff --git a/internal/Memorygraph/graph.go b/internal/Memorygraph/memorygraph.go similarity index 98% rename from internal/Memorygraph/graph.go rename to internal/Memorygraph/memorygraph.go index 8bee0fa..01e82ac 100644 --- a/internal/Memorygraph/graph.go +++ b/internal/Memorygraph/memorygraph.go @@ -381,6 +381,13 @@ type SearchResult struct { // SearchGraph finds nodes whose label or content matches query, then expands // one hop to collect linked neighbors. Results are scored by relevance. func SearchGraph(rootDir, query string, maxDepth, topK int, edgeFilter []RelationType) (*SearchResult, error) { + if strings.TrimSpace(query) == "" { + g, err := load(rootDir) + if err != nil { + return nil, err + } + return &SearchResult{TotalNodes: len(g.Nodes), TotalEdges: len(g.Edges)}, nil + } if maxDepth <= 0 { maxDepth = 2 } From d643379edaa87fe9c74e1a30e57fc78ac003f46b Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 09:30:59 -0400 Subject: [PATCH 17/32] Delete internal/Memorygraph/Memorygraph.go Patch will replace this --- internal/Memorygraph/Memorygraph.go | 659 ---------------------------- 1 file changed, 659 deletions(-) delete mode 100644 internal/Memorygraph/Memorygraph.go diff --git a/internal/Memorygraph/Memorygraph.go b/internal/Memorygraph/Memorygraph.go deleted file mode 100644 index c494c91..0000000 --- a/internal/Memorygraph/Memorygraph.go +++ /dev/null @@ -1,659 +0,0 @@ - Package memorygraph implements a persistent memory graph for interlinked RAG. -// Nodes represent typed knowledge units; edges are weighted, typed relations. -// The graph is stored as a single JSON file under rootDir/.supermodel/memory-graph.json -// and is safe for concurrent reads within a process (writes hold a mutex). -package Memorygraph - -import ( - "encoding/json" - "fmt" - "math" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" -) - -// --- Types ------------------------------------------------------------------- - -// NodeType classifies what kind of knowledge a node represents. -type NodeType string - -const ( - NodeTypeFact NodeType = "fact" - NodeTypeConcept NodeType = "concept" - NodeTypeEntity NodeType = "entity" - NodeTypeEvent NodeType = "event" - NodeTypeProcedure NodeType = "procedure" - NodeTypeContext NodeType = "context" -) - -// RelationType classifies the semantic relationship between two nodes. -type RelationType string - -const ( - RelationRelatedTo RelationType = "related_to" - RelationDependsOn RelationType = "depends_on" - RelationPartOf RelationType = "part_of" - RelationLeadsTo RelationType = "leads_to" - RelationContrasts RelationType = "contrasts" - RelationSimilarTo RelationType = "similar_to" - RelationInstantiates RelationType = "instantiates" -) - -// Node is a single knowledge unit in the memory graph. -type Node struct { - ID string `json:"id"` - Type NodeType `json:"type"` - Label string `json:"label"` - Content string `json:"content"` - Metadata map[string]string `json:"metadata,omitempty"` - AccessCount int `json:"accessCount"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// Edge is a directed, weighted relation between two nodes. -type Edge struct { - ID string `json:"id"` - Source string `json:"source"` - Target string `json:"target"` - Relation RelationType `json:"relation"` - Weight float64 `json:"weight"` - Metadata map[string]string `json:"metadata,omitempty"` -} - -// TraversalResult is a node reached during graph traversal, enriched with -// path context and a relevance score. -type TraversalResult struct { - Node Node - Depth int - RelevanceScore float64 - PathRelations []string // relation labels along the path from the start node -} - -// GraphStats summarises the current state of the graph. -type GraphStats struct { - Nodes int - Edges int -} - -// graphData is the on-disk format. -type graphData struct { - Nodes []Node `json:"nodes"` - Edges []Edge `json:"edges"` -} - -// --- Storage ----------------------------------------------------------------- - -const graphFile = ".supermodel/memory-graph.json" - -var ( - mu sync.RWMutex - cache = map[string]*graphData{} // rootDir → loaded graph -) - -func graphPath(rootDir string) string { - return filepath.Join(rootDir, graphFile) -} - -// load reads the graph for rootDir from disk (or returns the in-memory cache). -func load(rootDir string) (*graphData, error) { - mu.RLock() - if g, ok := cache[rootDir]; ok { - mu.RUnlock() - return g, nil - } - mu.RUnlock() - - mu.Lock() - defer mu.Unlock() - - // Double-checked locking. - if g, ok := cache[rootDir]; ok { - return g, nil - } - - path := graphPath(rootDir) - g := &graphData{} - - data, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("memorygraph: read %s: %w", path, err) - } - if err == nil { - if err := json.Unmarshal(data, g); err != nil { - return nil, fmt.Errorf("memorygraph: parse %s: %w", path, err) - } - } - - cache[rootDir] = g - return g, nil -} - -// save persists g to disk. Caller must hold mu (write lock). -func save(rootDir string, g *graphData) error { - path := graphPath(rootDir) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("memorygraph: mkdir: %w", err) - } - data, err := json.MarshalIndent(g, "", " ") - if err != nil { - return fmt.Errorf("memorygraph: marshal: %w", err) - } - tmp, err := os.CreateTemp(filepath.Dir(path), ".memory-graph-*.json.tmp") - if err != nil { - return fmt.Errorf("memorygraph: tempfile: %w", err) - } - tmpName := tmp.Name() - if _, err := tmp.Write(data); err != nil { - tmp.Close() - os.Remove(tmpName) - return fmt.Errorf("memorygraph: write: %w", err) - } - if err := tmp.Sync(); err != nil { - tmp.Close() - os.Remove(tmpName) - return fmt.Errorf("memorygraph: fsync: %w", err) - } - if err := tmp.Close(); err != nil { - os.Remove(tmpName) - return fmt.Errorf("memorygraph: close: %w", err) - } - if err := os.Rename(tmpName, path); err != nil { - os.Remove(tmpName) - return fmt.Errorf("memorygraph: rename: %w", err) - } - return nil -} - -// nodeID derives a stable deterministic ID from type+label. -func nodeID(t NodeType, label string) string { - return fmt.Sprintf("%s:%s", t, strings.ToLower(strings.ReplaceAll(label, " ", "_"))) -} - -// edgeID derives a stable deterministic ID from endpoints+relation. -func edgeID(source, target string, relation RelationType) string { - return fmt.Sprintf("%s--%s-->%s", source, relation, target) -} - -// --- Core operations --------------------------------------------------------- - -// UpsertNode creates or updates a node identified by (type, label). -// If the node already exists its content and metadata are updated in-place. -func UpsertNode(rootDir string, t NodeType, label, content string, metadata map[string]string) (*Node, error) { - mu.Lock() - defer mu.Unlock() - - g, err := loadLocked(rootDir) - if err != nil { - return nil, err - } - - id := nodeID(t, label) - now := time.Now().UTC() - - for i := range g.Nodes { - if g.Nodes[i].ID == id { - g.Nodes[i].Content = content - g.Nodes[i].Metadata = metadata - g.Nodes[i].UpdatedAt = now - g.Nodes[i].AccessCount++ - node := g.Nodes[i] - if err := save(rootDir, g); err != nil { - return nil, err - } - return &node, nil - } - } - - node := Node{ - ID: id, - Type: t, - Label: label, - Content: content, - Metadata: metadata, - CreatedAt: now, - UpdatedAt: now, - } - g.Nodes = append(g.Nodes, node) - if err := save(rootDir, g); err != nil { - return nil, err - } - return &node, nil -} - -// CreateRelation adds a directed edge between two existing nodes. -// Returns nil if either node ID is not found. -func CreateRelation(rootDir, sourceID, targetID string, relation RelationType, weight float64, metadata map[string]string) (*Edge, error) { - mu.Lock() - defer mu.Unlock() - - g, err := loadLocked(rootDir) - if err != nil { - return nil, err - } - - if !nodeExists(g, sourceID) || !nodeExists(g, targetID) { - return nil, nil //nolint:nilnil // caller checks nil to detect missing nodes - } - - if weight <= 0 { - weight = 1.0 - } - - id := edgeID(sourceID, targetID, relation) - - // Upsert: update weight if edge already exists. - for i := range g.Edges { - if g.Edges[i].ID == id { - g.Edges[i].Weight = weight - g.Edges[i].Metadata = metadata - edge := g.Edges[i] - if err := save(rootDir, g); err != nil { - return nil, err - } - return &edge, nil - } - } - - edge := Edge{ - ID: id, - Source: sourceID, - Target: targetID, - Relation: relation, - Weight: weight, - Metadata: metadata, - } - g.Edges = append(g.Edges, edge) - if err := save(rootDir, g); err != nil { - return nil, err - } - return &edge, nil -} - -// GetGraphStats returns a snapshot of node and edge counts. -func GetGraphStats(rootDir string) (GraphStats, error) { - g, err := load(rootDir) - if err != nil { - return GraphStats{}, err - } - return GraphStats{Nodes: len(g.Nodes), Edges: len(g.Edges)}, nil -} - -// PruneResult reports what was removed during a prune pass. -type PruneResult struct { - Removed int - Remaining int -} - -// PruneStaleLinks removes edges whose weight falls below threshold and then -// removes any nodes that have become fully orphaned (no edges in or out). -func PruneStaleLinks(rootDir string, threshold float64) (PruneResult, error) { - if threshold <= 0 { - threshold = 0.1 - } - - mu.Lock() - defer mu.Unlock() - - g, err := loadLocked(rootDir) - if err != nil { - return PruneResult{}, err - } - - removed := 0 - - // Remove weak edges. - live := g.Edges[:0] - for _, e := range g.Edges { - if e.Weight >= threshold { - live = append(live, e) - } else { - removed++ - } - } - g.Edges = live - - // Remove orphaned nodes (no remaining edges reference them). - connected := make(map[string]bool, len(g.Nodes)) - for _, e := range g.Edges { - connected[e.Source] = true - connected[e.Target] = true - } - liveNodes := g.Nodes[:0] - for _, n := range g.Nodes { - if connected[n.ID] { - liveNodes = append(liveNodes, n) - } else { - removed++ - } - } - g.Nodes = liveNodes - -< truncated lines 316-378 > -} - - // ... rest of the function unchanged -// SearchGraph finds nodes whose label or content matches query, then expands -// one hop to collect linked neighbors. Results are scored by relevance. -func SearchGraph(rootDir, query string, maxDepth, topK int, edgeFilter []RelationType) (*SearchResult, error) { - if strings.TrimSpace(query) == "" { - g, err := load(rootDir) - if err != nil { - return nil, err - } - return &SearchResult{TotalNodes: len(g.Nodes), TotalEdges: len(g.Edges)}, nil - } - if maxDepth <= 0 { - maxDepth = 2 - } - if topK <= 0 { - topK = 10 - } - - g, err := load(rootDir) - if err != nil { - return nil, err - } - - queryLower := strings.ToLower(query) - nodeByID := indexNodes(g) - adjOut := buildAdjacency(g, edgeFilter) // nodeID → []Edge - - // Score all nodes against the query. - type scored struct { - node Node - score float64 - } - var candidates []scored - for _, n := range g.Nodes { - score := scoreNode(n, queryLower) - if score > 0 { - candidates = append(candidates, scored{node: n, score: score}) - } - } - sort.Slice(candidates, func(i, j int) bool { - return candidates[i].score > candidates[j].score - }) - if len(candidates) > topK { - candidates = candidates[:topK] - } - - result := &SearchResult{ - TotalNodes: len(g.Nodes), - TotalEdges: len(g.Edges), - } - directIDs := make(map[string]bool) - for _, c := range candidates { - result.Direct = append(result.Direct, TraversalResult{ - Node: c.node, - Depth: 0, - RelevanceScore: c.score, - PathRelations: []string{}, - }) - directIDs[c.node.ID] = true - } - - // Expand one hop of neighbors. - neighborIDs := make(map[string]bool) - for _, c := range candidates { - for _, edge := range adjOut[c.node.ID] { - if directIDs[edge.Target] || neighborIDs[edge.Target] { - continue - } - if n, ok := nodeByID[edge.Target]; ok { - neighborIDs[edge.Target] = true - score := scoreNode(n, queryLower) * edge.Weight * 0.5 - result.Neighbors = append(result.Neighbors, TraversalResult{ - Node: n, - Depth: 1, - RelevanceScore: score, - PathRelations: []string{string(edge.Relation)}, - }) - } - } - } - sort.Slice(result.Neighbors, func(i, j int) bool { - return result.Neighbors[i].RelevanceScore > result.Neighbors[j].RelevanceScore - }) - - // Bump access counts for returned nodes. - go func() { _ = bumpAccess(rootDir, directIDs) }() - - return result, nil -} - -// RetrieveWithTraversal performs a BFS/depth-limited walk starting from -// startNodeID, returning all reachable nodes up to maxDepth hops away. -// edgeFilter restricts which relation types are followed; nil follows all. -func RetrieveWithTraversal(rootDir, startNodeID string, maxDepth int, edgeFilter []RelationType) ([]TraversalResult, error) { - if maxDepth <= 0 { - maxDepth = 2 - } - - g, err := load(rootDir) - if err != nil { - return nil, err - } - - nodeByID := indexNodes(g) - startNode, ok := nodeByID[startNodeID] - if !ok { - return nil, nil - } - - adjOut := buildAdjacency(g, edgeFilter) - - type queueItem struct { - nodeID string - depth int - pathRelations []string - score float64 - } - - visited := map[string]bool{startNodeID: true} - queue := []queueItem{{nodeID: startNodeID, depth: 0, pathRelations: []string{}, score: 1.0}} - var results []TraversalResult - - results = append(results, TraversalResult{ - Node: startNode, - Depth: 0, - RelevanceScore: 1.0, - PathRelations: []string{}, - }) - - for len(queue) > 0 { - item := queue[0] - queue = queue[1:] - - if item.depth >= maxDepth { - continue - } - - for _, edge := range adjOut[item.nodeID] { - if visited[edge.Target] { - continue - } - visited[edge.Target] = true - - n, ok := nodeByID[edge.Target] - if !ok { - continue - } - - // Decay relevance with depth and edge weight. - score := item.score * edge.Weight * math.Pow(0.8, float64(item.depth+1)) - pathRels := append(append([]string(nil), item.pathRelations...), string(edge.Relation)) - - results = append(results, TraversalResult{ - Node: n, - Depth: item.depth + 1, - RelevanceScore: score, - PathRelations: pathRels, - }) - queue = append(queue, queueItem{ - nodeID: edge.Target, - depth: item.depth + 1, - pathRelations: pathRels, - score: score, - }) - } - } - - // Sort by depth first, then descending relevance. - sort.Slice(results, func(i, j int) bool { - if results[i].Depth != results[j].Depth { - return results[i].Depth < results[j].Depth - } - return results[i].RelevanceScore > results[j].RelevanceScore - }) - - go func() { - ids := make(map[string]bool, len(results)) - for _, r := range results { - ids[r.Node.ID] = true - } - _ = bumpAccess(rootDir, ids) - }() - - return results, nil -} - -// --- Internal helpers -------------------------------------------------------- - -// loadLocked loads the graph assuming the caller already holds mu (write lock). -// It reads directly from the in-memory cache or from disk without acquiring -// any additional locks (caller already holds the write lock). -func loadLocked(rootDir string) (*graphData, error) { - if g, ok := cache[rootDir]; ok { - return g, nil - } - path := graphPath(rootDir) - g := &graphData{} - data, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("memorygraph: read %s: %w", path, err) - } - if err == nil { - if err := json.Unmarshal(data, g); err != nil { - return nil, fmt.Errorf("memorygraph: parse %s: %w", path, err) - } - } - cache[rootDir] = g - return g, nil -} - -func nodeExists(g *graphData, id string) bool { - for _, n := range g.Nodes { - if n.ID == id { - return true - } - } - return false -} - -func indexNodes(g *graphData) map[string]Node { - m := make(map[string]Node, len(g.Nodes)) - for _, n := range g.Nodes { - m[n.ID] = n - } - return m -} - -// buildAdjacency returns a map of nodeID → outgoing edges, optionally filtered -// by relation type. -func buildAdjacency(g *graphData, filter []RelationType) map[string][]Edge { - allowed := make(map[RelationType]bool, len(filter)) - for _, r := range filter { - allowed[r] = true - } - - adj := make(map[string][]Edge) - for _, e := range g.Edges { - if len(filter) > 0 && !allowed[e.Relation] { - continue - } - adj[e.Source] = append(adj[e.Source], e) - } - return adj -} - -// scoreNode scores a node against a lower-cased query string. -// Label matches are weighted more heavily than content matches. -func scoreNode(n Node, queryLower string) float64 { - labelLower := strings.ToLower(n.Label) - contentLower := strings.ToLower(n.Content) - - var score float64 - if strings.Contains(labelLower, queryLower) { - score += 1.0 - } - if strings.Contains(contentLower, queryLower) { - // Partial overlap: proportion of query tokens found in content. - score += tokenOverlap(queryLower, contentLower) * 0.6 - } - // Popularity bias: nodes accessed frequently are slightly preferred. - if n.AccessCount > 0 { - score += math.Log1p(float64(n.AccessCount)) * 0.05 - } - return score -} - -// tokenOverlap returns the fraction of query tokens present in text. -func tokenOverlap(query, text string) float64 { - queryTokens := strings.Fields(query) - if len(queryTokens) == 0 { - return 0 - } - found := 0 - for _, t := range queryTokens { - if strings.Contains(text, t) { - found++ - } - } - return float64(found) / float64(len(queryTokens)) -} - -// jaccardSimilarity approximates cosine similarity via token-set Jaccard. -func jaccardSimilarity(a, b string) float64 { - ta := tokenSet(a) - tb := tokenSet(b) - if len(ta) == 0 || len(tb) == 0 { - return 0 - } - intersection := 0 - for t := range ta { - if tb[t] { - intersection++ - } - } - return float64(intersection) / float64(len(ta)+len(tb)-intersection) -} - -func tokenSet(s string) map[string]bool { - m := make(map[string]bool) - for _, t := range strings.Fields(strings.ToLower(s)) { - m[t] = true - } - return m -} - -// bumpAccess increments the AccessCount for each node in ids and persists. -func bumpAccess(rootDir string, ids map[string]bool) error { - mu.Lock() - defer mu.Unlock() - - g, ok := cache[rootDir] - if !ok { - return nil - } - for i := range g.Nodes { - if ids[g.Nodes[i].ID] { - g.Nodes[i].AccessCount++ - } - } - return save(rootDir, g) -} From d8609412c652ec0c9df10443efde010cbd27d9a2 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 09:35:26 -0400 Subject: [PATCH 18/32] Rename memorygraph.go to Memory-graph.go --- internal/Memorygraph/{memorygraph.go => Memory-graph.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/Memorygraph/{memorygraph.go => Memory-graph.go} (100%) diff --git a/internal/Memorygraph/memorygraph.go b/internal/Memorygraph/Memory-graph.go similarity index 100% rename from internal/Memorygraph/memorygraph.go rename to internal/Memorygraph/Memory-graph.go From c4239458ad33cf11328d63ada1fdb00c7b28bfc9 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 16:55:54 -0400 Subject: [PATCH 19/32] Delete cmd/Memory-graph.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicate for now. It’s failing the check with the linter (ignoring the truncation issue as the patch didn’t get place to this file as well) --- cmd/Memory-graph.go | 651 -------------------------------------------- 1 file changed, 651 deletions(-) delete mode 100644 cmd/Memory-graph.go diff --git a/cmd/Memory-graph.go b/cmd/Memory-graph.go deleted file mode 100644 index bef6f7c..0000000 --- a/cmd/Memory-graph.go +++ /dev/null @@ -1,651 +0,0 @@ -// Package memorygraph implements a persistent memory graph for interlinked RAG. -// Nodes represent typed knowledge units; edges are weighted, typed relations. -// The graph is stored as a single JSON file under rootDir/.supermodel/memory-graph.json -// and is safe for concurrent reads within a process (writes hold a mutex). -package Memorygraph - -import ( - "encoding/json" - "fmt" - "math" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" -) - -// --- Types ------------------------------------------------------------------- - -// NodeType classifies what kind of knowledge a node represents. -type NodeType string - -const ( - NodeTypeFact NodeType = "fact" - NodeTypeConcept NodeType = "concept" - NodeTypeEntity NodeType = "entity" - NodeTypeEvent NodeType = "event" - NodeTypeProcedure NodeType = "procedure" - NodeTypeContext NodeType = "context" -) - -// RelationType classifies the semantic relationship between two nodes. -type RelationType string - -const ( - RelationRelatedTo RelationType = "related_to" - RelationDependsOn RelationType = "depends_on" - RelationPartOf RelationType = "part_of" - RelationLeadsTo RelationType = "leads_to" - RelationContrasts RelationType = "contrasts" - RelationSimilarTo RelationType = "similar_to" - RelationInstantiates RelationType = "instantiates" -) - -// Node is a single knowledge unit in the memory graph. -type Node struct { - ID string `json:"id"` - Type NodeType `json:"type"` - Label string `json:"label"` - Content string `json:"content"` - Metadata map[string]string `json:"metadata,omitempty"` - AccessCount int `json:"accessCount"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// Edge is a directed, weighted relation between two nodes. -type Edge struct { - ID string `json:"id"` - Source string `json:"source"` - Target string `json:"target"` - Relation RelationType `json:"relation"` - Weight float64 `json:"weight"` - Metadata map[string]string `json:"metadata,omitempty"` -} - -// TraversalResult is a node reached during graph traversal, enriched with -// path context and a relevance score. -type TraversalResult struct { - Node Node - Depth int - RelevanceScore float64 - PathRelations []string // relation labels along the path from the start node -} - -// GraphStats summarises the current state of the graph. -type GraphStats struct { - Nodes int - Edges int -} - -// graphData is the on-disk format. -type graphData struct { - Nodes []Node `json:"nodes"` - Edges []Edge `json:"edges"` -} - -// --- Storage ----------------------------------------------------------------- - -const graphFile = ".supermodel/memory-graph.json" - -var ( - mu sync.RWMutex - cache = map[string]*graphData{} // rootDir → loaded graph -) - -func graphPath(rootDir string) string { - return filepath.Join(rootDir, graphFile) -} - -// load reads the graph for rootDir from disk (or returns the in-memory cache). -func load(rootDir string) (*graphData, error) { - mu.RLock() - if g, ok := cache[rootDir]; ok { - mu.RUnlock() - return g, nil - } - mu.RUnlock() - - mu.Lock() - defer mu.Unlock() - - // Double-checked locking. - if g, ok := cache[rootDir]; ok { - return g, nil - } - - path := graphPath(rootDir) - g := &graphData{} - - data, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("memorygraph: read %s: %w", path, err) - } - if err == nil { - if err := json.Unmarshal(data, g); err != nil { - return nil, fmt.Errorf("memorygraph: parse %s: %w", path, err) - } - } - - cache[rootDir] = g - return g, nil -} - -// save persists g to disk. Caller must hold mu (write lock). -func save(rootDir string, g *graphData) error { - path := graphPath(rootDir) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("memorygraph: mkdir: %w", err) - } - data, err := json.MarshalIndent(g, "", " ") - if err != nil { - return fmt.Errorf("memorygraph: marshal: %w", err) - } - tmp, err := os.CreateTemp(filepath.Dir(path), ".memory-graph-*.json.tmp") - if err != nil { - return fmt.Errorf("memorygraph: tempfile: %w", err) - } - tmpName := tmp.Name() - if _, err := tmp.Write(data); err != nil { - tmp.Close() - os.Remove(tmpName) - return fmt.Errorf("memorygraph: write: %w", err) - } - if err := tmp.Sync(); err != nil { - tmp.Close() - os.Remove(tmpName) - return fmt.Errorf("memorygraph: fsync: %w", err) - } - if err := tmp.Close(); err != nil { - os.Remove(tmpName) - return fmt.Errorf("memorygraph: close: %w", err) - } - if err := os.Rename(tmpName, path); err != nil { - os.Remove(tmpName) - return fmt.Errorf("memorygraph: rename: %w", err) - } - return nil -} - -// nodeID derives a stable deterministic ID from type+label. -func nodeID(t NodeType, label string) string { - return fmt.Sprintf("%s:%s", t, strings.ToLower(strings.ReplaceAll(label, " ", "_"))) -} - -// edgeID derives a stable deterministic ID from endpoints+relation. -func edgeID(source, target string, relation RelationType) string { - return fmt.Sprintf("%s--%s-->%s", source, relation, target) -} - -// --- Core operations --------------------------------------------------------- - -// UpsertNode creates or updates a node identified by (type, label). -// If the node already exists its content and metadata are updated in-place. -func UpsertNode(rootDir string, t NodeType, label, content string, metadata map[string]string) (*Node, error) { - mu.Lock() - defer mu.Unlock() - - g, err := loadLocked(rootDir) - if err != nil { - return nil, err - } - - id := nodeID(t, label) - now := time.Now().UTC() - - for i := range g.Nodes { - if g.Nodes[i].ID == id { - g.Nodes[i].Content = content - g.Nodes[i].Metadata = metadata - g.Nodes[i].UpdatedAt = now - g.Nodes[i].AccessCount++ - node := g.Nodes[i] - if err := save(rootDir, g); err != nil { - return nil, err - } - return &node, nil - } - } - - node := Node{ - ID: id, - Type: t, - Label: label, - Content: content, - Metadata: metadata, - CreatedAt: now, - UpdatedAt: now, - } - g.Nodes = append(g.Nodes, node) - if err := save(rootDir, g); err != nil { - return nil, err - } - return &node, nil -} - -// CreateRelation adds a directed edge between two existing nodes. -// Returns nil if either node ID is not found. -func CreateRelation(rootDir, sourceID, targetID string, relation RelationType, weight float64, metadata map[string]string) (*Edge, error) { - mu.Lock() - defer mu.Unlock() - - g, err := loadLocked(rootDir) - if err != nil { - return nil, err - } - - if !nodeExists(g, sourceID) || !nodeExists(g, targetID) { - return nil, nil //nolint:nilnil // caller checks nil to detect missing nodes - } - - if weight <= 0 { - weight = 1.0 - } - - id := edgeID(sourceID, targetID, relation) - - // Upsert: update weight if edge already exists. - for i := range g.Edges { - if g.Edges[i].ID == id { - g.Edges[i].Weight = weight - g.Edges[i].Metadata = metadata - edge := g.Edges[i] - if err := save(rootDir, g); err != nil { - return nil, err - } - return &edge, nil - } - } - - edge := Edge{ - ID: id, - Source: sourceID, - Target: targetID, - Relation: relation, - Weight: weight, - Metadata: metadata, - } - g.Edges = append(g.Edges, edge) - if err := save(rootDir, g); err != nil { - return nil, err - } - return &edge, nil -} - -// GetGraphStats returns a snapshot of node and edge counts. -func GetGraphStats(rootDir string) (GraphStats, error) { - g, err := load(rootDir) - if err != nil { - return GraphStats{}, err - } - return GraphStats{Nodes: len(g.Nodes), Edges: len(g.Edges)}, nil -} - -// PruneResult reports what was removed during a prune pass. -type PruneResult struct { - Removed int - Remaining int -} - -// PruneStaleLinks removes edges whose weight falls below threshold and then -// removes any nodes that have become fully orphaned (no edges in or out). -func PruneStaleLinks(rootDir string, threshold float64) (PruneResult, error) { - if threshold <= 0 { - threshold = 0.1 - } - - mu.Lock() - defer mu.Unlock() - - g, err := loadLocked(rootDir) - if err != nil { - return PruneResult{}, err - } - - removed := 0 - - // Remove weak edges. - live := g.Edges[:0] - for _, e := range g.Edges { - if e.Weight >= threshold { - live = append(live, e) - } else { - removed++ - } - } - g.Edges = live - - // Remove orphaned nodes (no remaining edges reference them). - connected := make(map[string]bool, len(g.Nodes)) - for _, e := range g.Edges { - connected[e.Source] = true - connected[e.Target] = true - } - liveNodes := g.Nodes[:0] - for _, n := range g.Nodes { - if connected[n.ID] { - liveNodes = append(liveNodes, n) - } else { - removed++ - } - } - g.Nodes = liveNodes - -< truncated lines 316-378 > -} - -// SearchGraph finds nodes whose label or content matches query, then expands -// one hop to collect linked neighbors. Results are scored by relevance. -func SearchGraph(rootDir, query string, maxDepth, topK int, edgeFilter []RelationType) (*SearchResult, error) { - if maxDepth <= 0 { - maxDepth = 2 - } - if topK <= 0 { - topK = 10 - } - - g, err := load(rootDir) - if err != nil { - return nil, err - } - - queryLower := strings.ToLower(query) - nodeByID := indexNodes(g) - adjOut := buildAdjacency(g, edgeFilter) // nodeID → []Edge - - // Score all nodes against the query. - type scored struct { - node Node - score float64 - } - var candidates []scored - for _, n := range g.Nodes { - score := scoreNode(n, queryLower) - if score > 0 { - candidates = append(candidates, scored{node: n, score: score}) - } - } - sort.Slice(candidates, func(i, j int) bool { - return candidates[i].score > candidates[j].score - }) - if len(candidates) > topK { - candidates = candidates[:topK] - } - - result := &SearchResult{ - TotalNodes: len(g.Nodes), - TotalEdges: len(g.Edges), - } - directIDs := make(map[string]bool) - for _, c := range candidates { - result.Direct = append(result.Direct, TraversalResult{ - Node: c.node, - Depth: 0, - RelevanceScore: c.score, - PathRelations: []string{}, - }) - directIDs[c.node.ID] = true - } - - // Expand one hop of neighbors. - neighborIDs := make(map[string]bool) - for _, c := range candidates { - for _, edge := range adjOut[c.node.ID] { - if directIDs[edge.Target] || neighborIDs[edge.Target] { - continue - } - if n, ok := nodeByID[edge.Target]; ok { - neighborIDs[edge.Target] = true - score := scoreNode(n, queryLower) * edge.Weight * 0.5 - result.Neighbors = append(result.Neighbors, TraversalResult{ - Node: n, - Depth: 1, - RelevanceScore: score, - PathRelations: []string{string(edge.Relation)}, - }) - } - } - } - sort.Slice(result.Neighbors, func(i, j int) bool { - return result.Neighbors[i].RelevanceScore > result.Neighbors[j].RelevanceScore - }) - - // Bump access counts for returned nodes. - go func() { _ = bumpAccess(rootDir, directIDs) }() - - return result, nil -} - -// RetrieveWithTraversal performs a BFS/depth-limited walk starting from -// startNodeID, returning all reachable nodes up to maxDepth hops away. -// edgeFilter restricts which relation types are followed; nil follows all. -func RetrieveWithTraversal(rootDir, startNodeID string, maxDepth int, edgeFilter []RelationType) ([]TraversalResult, error) { - if maxDepth <= 0 { - maxDepth = 2 - } - - g, err := load(rootDir) - if err != nil { - return nil, err - } - - nodeByID := indexNodes(g) - startNode, ok := nodeByID[startNodeID] - if !ok { - return nil, nil - } - - adjOut := buildAdjacency(g, edgeFilter) - - type queueItem struct { - nodeID string - depth int - pathRelations []string - score float64 - } - - visited := map[string]bool{startNodeID: true} - queue := []queueItem{{nodeID: startNodeID, depth: 0, pathRelations: []string{}, score: 1.0}} - var results []TraversalResult - - results = append(results, TraversalResult{ - Node: startNode, - Depth: 0, - RelevanceScore: 1.0, - PathRelations: []string{}, - }) - - for len(queue) > 0 { - item := queue[0] - queue = queue[1:] - - if item.depth >= maxDepth { - continue - } - - for _, edge := range adjOut[item.nodeID] { - if visited[edge.Target] { - continue - } - visited[edge.Target] = true - - n, ok := nodeByID[edge.Target] - if !ok { - continue - } - - // Decay relevance with depth and edge weight. - score := item.score * edge.Weight * math.Pow(0.8, float64(item.depth+1)) - pathRels := append(append([]string(nil), item.pathRelations...), string(edge.Relation)) - - results = append(results, TraversalResult{ - Node: n, - Depth: item.depth + 1, - RelevanceScore: score, - PathRelations: pathRels, - }) - queue = append(queue, queueItem{ - nodeID: edge.Target, - depth: item.depth + 1, - pathRelations: pathRels, - score: score, - }) - } - } - - // Sort by depth first, then descending relevance. - sort.Slice(results, func(i, j int) bool { - if results[i].Depth != results[j].Depth { - return results[i].Depth < results[j].Depth - } - return results[i].RelevanceScore > results[j].RelevanceScore - }) - - go func() { - ids := make(map[string]bool, len(results)) - for _, r := range results { - ids[r.Node.ID] = true - } - _ = bumpAccess(rootDir, ids) - }() - - return results, nil -} - -// --- Internal helpers -------------------------------------------------------- - -// loadLocked loads the graph assuming the caller already holds mu (write lock). -// It reads directly from the in-memory cache or from disk without acquiring -// any additional locks (caller already holds the write lock). -func loadLocked(rootDir string) (*graphData, error) { - if g, ok := cache[rootDir]; ok { - return g, nil - } - path := graphPath(rootDir) - g := &graphData{} - data, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("memorygraph: read %s: %w", path, err) - } - if err == nil { - if err := json.Unmarshal(data, g); err != nil { - return nil, fmt.Errorf("memorygraph: parse %s: %w", path, err) - } - } - cache[rootDir] = g - return g, nil -} - -func nodeExists(g *graphData, id string) bool { - for _, n := range g.Nodes { - if n.ID == id { - return true - } - } - return false -} - -func indexNodes(g *graphData) map[string]Node { - m := make(map[string]Node, len(g.Nodes)) - for _, n := range g.Nodes { - m[n.ID] = n - } - return m -} - -// buildAdjacency returns a map of nodeID → outgoing edges, optionally filtered -// by relation type. -func buildAdjacency(g *graphData, filter []RelationType) map[string][]Edge { - allowed := make(map[RelationType]bool, len(filter)) - for _, r := range filter { - allowed[r] = true - } - - adj := make(map[string][]Edge) - for _, e := range g.Edges { - if len(filter) > 0 && !allowed[e.Relation] { - continue - } - adj[e.Source] = append(adj[e.Source], e) - } - return adj -} - -// scoreNode scores a node against a lower-cased query string. -// Label matches are weighted more heavily than content matches. -func scoreNode(n Node, queryLower string) float64 { - labelLower := strings.ToLower(n.Label) - contentLower := strings.ToLower(n.Content) - - var score float64 - if strings.Contains(labelLower, queryLower) { - score += 1.0 - } - if strings.Contains(contentLower, queryLower) { - // Partial overlap: proportion of query tokens found in content. - score += tokenOverlap(queryLower, contentLower) * 0.6 - } - // Popularity bias: nodes accessed frequently are slightly preferred. - if n.AccessCount > 0 { - score += math.Log1p(float64(n.AccessCount)) * 0.05 - } - return score -} - -// tokenOverlap returns the fraction of query tokens present in text. -func tokenOverlap(query, text string) float64 { - queryTokens := strings.Fields(query) - if len(queryTokens) == 0 { - return 0 - } - found := 0 - for _, t := range queryTokens { - if strings.Contains(text, t) { - found++ - } - } - return float64(found) / float64(len(queryTokens)) -} - -// jaccardSimilarity approximates cosine similarity via token-set Jaccard. -func jaccardSimilarity(a, b string) float64 { - ta := tokenSet(a) - tb := tokenSet(b) - if len(ta) == 0 || len(tb) == 0 { - return 0 - } - intersection := 0 - for t := range ta { - if tb[t] { - intersection++ - } - } - return float64(intersection) / float64(len(ta)+len(tb)-intersection) -} - -func tokenSet(s string) map[string]bool { - m := make(map[string]bool) - for _, t := range strings.Fields(strings.ToLower(s)) { - m[t] = true - } - return m -} - -// bumpAccess increments the AccessCount for each node in ids and persists. -func bumpAccess(rootDir string, ids map[string]bool) error { - mu.Lock() - defer mu.Unlock() - - g, ok := cache[rootDir] - if !ok { - return nil - } - for i := range g.Nodes { - if ids[g.Nodes[i].ID] { - g.Nodes[i].AccessCount++ - } - } - return save(rootDir, g) -} From 159d46daa107d9df12e100ee75404efa487286d3 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 16:56:39 -0400 Subject: [PATCH 20/32] Delete cmd/Peek.go --- cmd/Peek.go | 223 ---------------------------------------------------- 1 file changed, 223 deletions(-) delete mode 100644 cmd/Peek.go diff --git a/cmd/Peek.go b/cmd/Peek.go deleted file mode 100644 index c068557..0000000 --- a/cmd/Peek.go +++ /dev/null @@ -1,223 +0,0 @@ -package memorygraph - -import ( - "fmt" - "strings" - "time" -) - -// NodePeek is a full snapshot of a node and all its edges, returned by Peek. -type NodePeek struct { - Node Node - EdgesOut []EdgePeek // edges where this node is the source - EdgesIn []EdgePeek // edges where this node is the target -} - -// EdgePeek is a human-readable summary of a single edge and its peer node. -type EdgePeek struct { - Edge Edge - PeerID string - PeerLabel string - PeerType NodeType -} - -// PeekOptions controls what Peek returns. -type PeekOptions struct { - RootDir string - // NodeID takes priority if set. - NodeID string - // Label is used for lookup when NodeID is empty (first match wins). - Label string -} - -// Peek returns a full NodePeek for the requested node: its content, metadata, -// access stats, and every inbound/outbound edge with peer labels resolved. -// Returns nil if the node cannot be found. -func Peek(opts PeekOptions) (*NodePeek, error) { - g, err := load(opts.RootDir) - if err != nil { - return nil, err - } - - nodeByID := indexNodes(g) - - // Resolve target node. - var target *Node - if opts.NodeID != "" { - if n, ok := nodeByID[opts.NodeID]; ok { - target = &n - } - } else if opts.Label != "" { - labelLower := strings.ToLower(opts.Label) - for i := range g.Nodes { - if strings.ToLower(g.Nodes[i].Label) == labelLower { - n := g.Nodes[i] - target = &n - break - } - } - } - - if target == nil { - return nil, nil //nolint:nilnil // caller checks nil to detect not-found - } - - peek := &NodePeek{Node: *target} - - for _, e := range g.Edges { - switch { - case e.Source == target.ID: - peer := nodeByID[e.Target] - peek.EdgesOut = append(peek.EdgesOut, EdgePeek{ - Edge: e, - PeerID: e.Target, - PeerLabel: peer.Label, - PeerType: peer.Type, - }) - case e.Target == target.ID: - peer := nodeByID[e.Source] - peek.EdgesIn = append(peek.EdgesIn, EdgePeek{ - Edge: e, - PeerID: e.Source, - PeerLabel: peer.Label, - PeerType: peer.Type, - }) - } - } - - return peek, nil -} - -// PeekList returns a lightweight summary of every node in the graph — -// ID, type, label, access count, age, and edge degree — sorted by access -// count descending. Useful for scanning the graph before pruning. -func PeekList(rootDir string) ([]NodePeek, error) { - g, err := load(rootDir) - if err != nil { - return nil, err - } - - nodeByID := indexNodes(g) - - edgesOut := make(map[string][]EdgePeek, len(g.Nodes)) - edgesIn := make(map[string][]EdgePeek, len(g.Nodes)) - - for _, e := range g.Edges { - peer := nodeByID[e.Target] - edgesOut[e.Source] = append(edgesOut[e.Source], EdgePeek{ - Edge: e, PeerID: e.Target, PeerLabel: peer.Label, PeerType: peer.Type, - }) - - peer = nodeByID[e.Source] - edgesIn[e.Target] = append(edgesIn[e.Target], EdgePeek{ - Edge: e, PeerID: e.Source, PeerLabel: peer.Label, PeerType: peer.Type, - }) - } - - peeks := make([]NodePeek, 0, len(g.Nodes)) - for _, n := range g.Nodes { - peeks = append(peeks, NodePeek{ - Node: n, - EdgesOut: edgesOut[n.ID], - EdgesIn: edgesIn[n.ID], - }) - } - - // Sort by access count desc, then label asc for stable output. - sortNodePeeks(peeks) - - return peeks, nil -} - -// FormatPeek renders a NodePeek as a human-readable block suitable for -// display in a terminal or MCP tool response. -func FormatPeek(p *NodePeek) string { - if p == nil { - return "❌ Node not found." - } - n := p.Node - age := time.Since(n.CreatedAt).Round(time.Hour) - - var b strings.Builder - fmt.Fprintf(&b, "┌─ [%s] %s\n", n.Type, n.Label) - fmt.Fprintf(&b, "│ ID: %s\n", n.ID) - fmt.Fprintf(&b, "│ Accessed: %dx │ Age: %s │ Updated: %s\n", - n.AccessCount, - age, - n.UpdatedAt.Format("2006-01-02 15:04"), - ) - if len(n.Metadata) > 0 { - fmt.Fprintf(&b, "│ Metadata: %s\n", formatMetadata(n.Metadata)) - } - fmt.Fprintf(&b, "│\n│ Content:\n│ %s\n", - strings.ReplaceAll(n.Content, "\n", "\n│ ")) - - if len(p.EdgesOut) > 0 { - fmt.Fprintf(&b, "│\n│ Out (%d):\n", len(p.EdgesOut)) - for _, ep := range p.EdgesOut { - fmt.Fprintf(&b, "│ ──[%s w:%.2f]──▶ [%s] %s\n", - ep.Edge.Relation, ep.Edge.Weight, ep.PeerType, ep.PeerLabel) - } - } - if len(p.EdgesIn) > 0 { - fmt.Fprintf(&b, "│\n│ In (%d):\n", len(p.EdgesIn)) - for _, ep := range p.EdgesIn { - fmt.Fprintf(&b, "│ [%s] %s ──[%s w:%.2f]──▶\n", - ep.PeerType, ep.PeerLabel, ep.Edge.Relation, ep.Edge.Weight) - } - } - b.WriteString("└─") - return b.String() -} - -// FormatPeekList renders a PeekList result as a compact table with one node -// per line, suitable for scanning before a prune pass. -func FormatPeekList(peeks []NodePeek) string { - if len(peeks) == 0 { - return "Graph is empty." - } - var b strings.Builder - fmt.Fprintf(&b, "%-12s %-10s %-32s %7s %4s %4s\n", - "TYPE", "ID (short)", "LABEL", "ACCESSED", "OUT", "IN") - b.WriteString(strings.Repeat("─", 76) + "\n") - for _, p := range peeks { - shortID := p.Node.ID - if len(shortID) > 10 { - shortID = shortID[:10] + "…" - } - label := p.Node.Label - if len(label) > 32 { - label = label[:31] + "…" - } - fmt.Fprintf(&b, "%-12s %-10s %-32s %7dx %4d %4d\n", - p.Node.Type, shortID, label, - p.Node.AccessCount, len(p.EdgesOut), len(p.EdgesIn)) - } - fmt.Fprintf(&b, "\n%d node(s) total\n", len(peeks)) - return b.String() -} - -// --- helpers ----------------------------------------------------------------- - -func formatMetadata(m map[string]string) string { - parts := make([]string, 0, len(m)) - for k, v := range m { - parts = append(parts, k+"="+v) - } - return strings.Join(parts, " ") -} - -func sortNodePeeks(peeks []NodePeek) { - // Insertion sort is fine for the typical small N here. - for i := 1; i < len(peeks); i++ { - for j := i; j > 0; j-- { - a, b := peeks[j-1], peeks[j] - if a.Node.AccessCount < b.Node.AccessCount || - (a.Node.AccessCount == b.Node.AccessCount && a.Node.Label > b.Node.Label) { - peeks[j-1], peeks[j] = peeks[j], peeks[j-1] - } else { - break - } - } - } -} From ee44b504a8cc56ab4c959bf879755e456270635b Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 16:56:51 -0400 Subject: [PATCH 21/32] Delete cmd/tools.go --- cmd/tools.go | 245 --------------------------------------------------- 1 file changed, 245 deletions(-) delete mode 100644 cmd/tools.go diff --git a/cmd/tools.go b/cmd/tools.go deleted file mode 100644 index 9185945..0000000 --- a/cmd/tools.go +++ /dev/null @@ -1,245 +0,0 @@ -// Package memorygraph — MCP tool wrappers. -// Each exported Tool* function is the Go equivalent of the TypeScript tool -// functions in tools/memory-tools.ts and follows the same output format so -// that callers get identical text responses. -package memorygraph - -import ( - "fmt" - "strings" -) - -// --- Tool option structs ------------------------------------------------------ - -// UpsertMemoryNodeOptions mirrors UpsertMemoryNodeOptions in the TS source. -type UpsertMemoryNodeOptions struct { - RootDir string - Type NodeType - Label string - Content string - Metadata map[string]string -} - -// CreateRelationOptions mirrors CreateRelationOptions in the TS source. -type CreateRelationOptions struct { - RootDir string - SourceID string - TargetID string - Relation RelationType - Weight float64 - Metadata map[string]string -} - -// SearchMemoryGraphOptions mirrors SearchMemoryGraphOptions in the TS source. -type SearchMemoryGraphOptions struct { - RootDir string - Query string - MaxDepth int - TopK int - EdgeFilter []RelationType -} - -// PruneStaleLinksOptions mirrors PruneStaleLinksOptions in the TS source. -type PruneStaleLinksOptions struct { - RootDir string - Threshold float64 -} - -// InterlinkedItem is a single entry for AddInterlinkedContext. -type InterlinkedItem struct { - Type NodeType - Label string - Content string - Metadata map[string]string -} - -// AddInterlinkedContextOptions mirrors AddInterlinkedContextOptions in the TS source. -type AddInterlinkedContextOptions struct { - RootDir string - Items []InterlinkedItem - AutoLink bool -} - -// RetrieveWithTraversalOptions mirrors RetrieveWithTraversalOptions in the TS source. -type RetrieveWithTraversalOptions struct { - RootDir string - StartNodeID string - MaxDepth int - EdgeFilter []RelationType -} - -// --- Formatters -------------------------------------------------------------- - -func formatTraversalResult(r TraversalResult) string { - content := r.Node.Content - if len(content) > 120 { - content = content[:120] + "..." - } - lines := []string{ - fmt.Sprintf(" [%s] %s (depth: %d, score: %.2f)", r.Node.Type, r.Node.Label, r.Depth, r.RelevanceScore), - fmt.Sprintf(" Content: %s", content), - } - if len(r.PathRelations) > 1 { - lines = append(lines, fmt.Sprintf(" Path: %s", strings.Join(r.PathRelations, " "))) - } - lines = append(lines, fmt.Sprintf(" ID: %s | Accessed: %dx", r.Node.ID, r.Node.AccessCount)) - return strings.Join(lines, "\n") -} - -// --- Tool implementations ---------------------------------------------------- - -// ToolUpsertMemoryNode creates or updates a memory node and returns a -// human-readable summary including updated graph stats. -func ToolUpsertMemoryNode(opts UpsertMemoryNodeOptions) (string, error) { - node, err := UpsertNode(opts.RootDir, opts.Type, opts.Label, opts.Content, opts.Metadata) - if err != nil { - return "", err - } - stats, err := GetGraphStats(opts.RootDir) - if err != nil { - return "", err - } - return strings.Join([]string{ - fmt.Sprintf("✅ Memory node upserted: %s", node.Label), - fmt.Sprintf(" ID: %s", node.ID), - fmt.Sprintf(" Type: %s", node.Type), - fmt.Sprintf(" Access count: %d", node.AccessCount), - fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), - }, "\n"), nil -} - -// ToolCreateRelation adds a directed edge between two existing nodes. -func ToolCreateRelation(opts CreateRelationOptions) (string, error) { - edge, err := CreateRelation(opts.RootDir, opts.SourceID, opts.TargetID, opts.Relation, opts.Weight, opts.Metadata) - if err != nil { - return "", err - } - if edge == nil { - return fmt.Sprintf("❌ Failed: one or both node IDs not found (source: %s, target: %s)", - opts.SourceID, opts.TargetID), nil - } - stats, err := GetGraphStats(opts.RootDir) - if err != nil { - return "", err - } - return strings.Join([]string{ - fmt.Sprintf("✅ Relation created: %s --[%s]--> %s", opts.SourceID, edge.Relation, opts.TargetID), - fmt.Sprintf(" Edge ID: %s", edge.ID), - fmt.Sprintf(" Weight: %.2f", edge.Weight), - fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), - }, "\n"), nil -} - -// ToolSearchMemoryGraph searches the graph and returns direct matches plus -// one-hop neighbors, formatted identically to the TypeScript version. -func ToolSearchMemoryGraph(opts SearchMemoryGraphOptions) (string, error) { - result, err := SearchGraph(opts.RootDir, opts.Query, opts.MaxDepth, opts.TopK, opts.EdgeFilter) - if err != nil { - return "", err - } - if len(result.Direct) == 0 { - return fmt.Sprintf("No memory nodes found for: %q\nGraph has %d nodes, %d edges.", - opts.Query, result.TotalNodes, result.TotalEdges), nil - } - - sections := []string{ - fmt.Sprintf("Memory Graph Search: %q", opts.Query), - fmt.Sprintf("Graph: %d nodes, %d edges\n", result.TotalNodes, result.TotalEdges), - "Direct Matches:", - } - for _, hit := range result.Direct { - sections = append(sections, formatTraversalResult(hit)) - } - if len(result.Neighbors) > 0 { - sections = append(sections, "\nLinked Neighbors:") - for _, neighbor := range result.Neighbors { - sections = append(sections, formatTraversalResult(neighbor)) - } - } - return strings.Join(sections, "\n"), nil -} - -// ToolPruneStaleLinks removes weak edges and orphaned nodes. -func ToolPruneStaleLinks(opts PruneStaleLinksOptions) (string, error) { - result, err := PruneStaleLinks(opts.RootDir, opts.Threshold) - if err != nil { - return "", err - } - return strings.Join([]string{ - "🧹 Pruning complete", - fmt.Sprintf(" Removed: %d stale links/orphan nodes", result.Removed), - fmt.Sprintf(" Remaining edges: %d", result.Remaining), - }, "\n"), nil -} - -// ToolAddInterlinkedContext bulk-upserts nodes and optionally auto-links them -// by content similarity (threshold ≥ 0.72). -func ToolAddInterlinkedContext(opts AddInterlinkedContextOptions) (string, error) { - items := make([]struct { - Type NodeType - Label string - Content string - Metadata map[string]string - }, len(opts.Items)) - for i, it := range opts.Items { - items[i].Type = it.Type - items[i].Label = it.Label - items[i].Content = it.Content - items[i].Metadata = it.Metadata - } - - result, err := AddInterlinkedContext(opts.RootDir, items, opts.AutoLink) - if err != nil { - return "", err - } - - sections := []string{fmt.Sprintf("✅ Added %d interlinked nodes", len(result.Nodes))} - if len(result.Edges) > 0 { - sections = append(sections, fmt.Sprintf(" Auto-linked: %d similarity edges (threshold ≥ 0.72)", len(result.Edges))) - } else { - sections = append(sections, " No auto-links above threshold") - } - sections = append(sections, "\nNodes:") - for _, n := range result.Nodes { - sections = append(sections, fmt.Sprintf(" [%s] %s → %s", n.Type, n.Label, n.ID)) - } - if len(result.Edges) > 0 { - sections = append(sections, "\nEdges:") - for _, e := range result.Edges { - sections = append(sections, fmt.Sprintf(" %s --[%s w:%.2f]--> %s", - e.Source, e.Relation, e.Weight, e.Target)) - } - } - - stats, err := GetGraphStats(opts.RootDir) - if err != nil { - return "", err - } - sections = append(sections, fmt.Sprintf("\nGraph total: %d nodes, %d edges", stats.Nodes, stats.Edges)) - return strings.Join(sections, "\n"), nil -} - -// ToolRetrieveWithTraversal starts a BFS from startNodeID and returns all -// reachable nodes up to maxDepth, formatted with path context and scores. -func ToolRetrieveWithTraversal(opts RetrieveWithTraversalOptions) (string, error) { - results, err := RetrieveWithTraversal(opts.RootDir, opts.StartNodeID, opts.MaxDepth, opts.EdgeFilter) - if err != nil { - return "", err - } - if len(results) == 0 { - return fmt.Sprintf("❌ Node not found: %s", opts.StartNodeID), nil - } - - maxDepth := opts.MaxDepth - if maxDepth <= 0 { - maxDepth = 2 - } - - sections := []string{ - fmt.Sprintf("Traversal from: %s (depth limit: %d)\n", results[0].Node.Label, maxDepth), - } - for _, r := range results { - sections = append(sections, formatTraversalResult(r)) - } - return strings.Join(sections, "\n"), nil -} From 89a03757005fdf40769ab7979610ed61563d0038 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 17:17:01 -0400 Subject: [PATCH 22/32] Update server.go The ritual of Q --- internal/mcp/server.go | 165 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 1 deletion(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 6461f0e..7baab62 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -9,6 +9,7 @@ import ( "os" "strings" + "github.com/supermodeltools/cli/internal/Memorygraph" "github.com/supermodeltools/cli/internal/analyze" "github.com/supermodeltools/cli/internal/api" "github.com/supermodeltools/cli/internal/build" @@ -113,6 +114,80 @@ var tools = []tool{ }, }, }, + + { ++ Name: "upsert_memory_node", ++ Description: "Upsert a typed knowledge node into the persistent memory graph.", ++ InputSchema: toolSchema{ ++ Type: "object", ++ Properties: map[string]schemaProp{ ++ "type": {Type: "string", Description: "Node type: fact, concept, entity, event, procedure, context."}, ++ "label": {Type: "string", Description: "Short unique label for the node."}, ++ "content": {Type: "string", Description: "Full content body of the node."}, ++ }, ++ Required: []string{"type", "label", "content"}, ++ }, ++ }, ++ { ++ Name: "create_relation", ++ Description: "Create a directed weighted edge between two memory graph nodes.", ++ InputSchema: toolSchema{ ++ Type: "object", ++ Properties: map[string]schemaProp{ ++ "source_id": {Type: "string", Description: "ID of the source node."}, ++ "target_id": {Type: "string", Description: "ID of the target node."}, ++ "relation": {Type: "string", Description: "Relation type, e.g. related_to, depends_on, part_of."}, ++ "weight": {Type: "number", Description: "Edge weight between 0 and 1 (default 1.0)."}, ++ }, ++ Required: []string{"source_id", "target_id", "relation"}, ++ }, ++ }, ++ { ++ Name: "search_memory_graph", ++ Description: "Score and retrieve nodes from the memory graph matching a query, with optional one-hop neighbor expansion.", ++ InputSchema: toolSchema{ ++ Type: "object", ++ Properties: map[string]schemaProp{ ++ "query": {Type: "string", Description: "Search query string."}, ++ "max_depth": {Type: "integer", Description: "Max BFS depth for neighbor expansion (default 1)."}, ++ "top_k": {Type: "integer", Description: "Maximum number of direct results to return (default 5)."}, ++ }, ++ Required: []string{"query"}, ++ }, ++ }, ++ { ++ Name: "retrieve_with_traversal", ++ Description: "BFS traversal from a start node up to maxDepth, returning visited nodes with decayed relevance scores.", ++ InputSchema: toolSchema{ ++ Type: "object", ++ Properties: map[string]schemaProp{ ++ "start_node_id": {Type: "string", Description: "ID of the node to start traversal from."}, ++ "max_depth": {Type: "integer", Description: "Maximum BFS depth (default 3)."}, ++ }, ++ Required: []string{"start_node_id"}, ++ }, ++ }, ++ { ++ Name: "prune_stale_links", ++ Description: "Remove edges below a weight threshold and orphaned nodes from the memory graph.", ++ InputSchema: toolSchema{ ++ Type: "object", ++ Properties: map[string]schemaProp{ ++ "threshold": {Type: "number", Description: "Minimum edge weight to retain (default 0.1)."}, ++ }, ++ }, ++ }, ++ { ++ Name: "add_interlinked_context", ++ Description: "Bulk-insert nodes and optionally auto-create similarity edges (Jaccard ≥ 0.72) between them.", ++ InputSchema: toolSchema{ ++ Type: "object", ++ Properties: map[string]schemaProp{ ++ "items": {Type: "array", Description: "Array of {type, label, content, metadata} node objects to insert."}, ++ "auto_link": {Type: "boolean", Description: "If true, auto-create similarity edges between inserted nodes."}, ++ }, ++ Required: []string{"items"}, ++ }, ++ }, } // --- Server ------------------------------------------------------------------ @@ -226,7 +301,71 @@ func (s *server) callTool(ctx context.Context, name string, args map[string]any) return s.toolGetGraph(ctx, args) default: return "", fmt.Errorf("unknown tool: %s", name) - } + func (s *server) callTool(ctx context.Context, name string, args map[string]any) (string, error) { + switch name { + case "analyze": + return s.toolAnalyze(ctx, args) + case "dead_code": + return s.toolDeadCode(ctx, args) + case "blast_radius": + return s.toolBlastRadius(ctx, args) + case "get_graph": + return s.toolGetGraph(ctx, args) ++ case "upsert_memory_node": ++ return memorygraph.ToolUpsertMemoryNode(memorygraph.UpsertMemoryNodeOptions{ ++ RootDir: s.dir, ++ Type: memorygraph.NodeType(strArg(args, "type")), ++ Label: strArg(args, "label"), ++ Content: strArg(args, "content"), ++ }) ++ case "create_relation": ++ w := floatArg(args, "weight") ++ if w == 0 { ++ w = 1.0 ++ } ++ return memorygraph.ToolCreateRelation(memorygraph.CreateRelationOptions{ ++ RootDir: s.dir, ++ SourceID: strArg(args, "source_id"), ++ TargetID: strArg(args, "target_id"), ++ Relation: memorygraph.RelationType(strArg(args, "relation")), ++ Weight: w, ++ }) ++ case "search_memory_graph": ++ topK := intArg(args, "top_k") ++ if topK == 0 { ++ topK = 5 ++ } ++ return memorygraph.ToolSearchMemoryGraph(memorygraph.SearchMemoryGraphOptions{ ++ RootDir: s.dir, ++ Query: strArg(args, "query"), ++ MaxDepth: intArg(args, "max_depth"), ++ TopK: topK, ++ }) ++ case "retrieve_with_traversal": ++ return memorygraph.ToolRetrieveWithTraversal(memorygraph.RetrieveWithTraversalOptions{ ++ RootDir: s.dir, ++ StartNodeID: strArg(args, "start_node_id"), ++ MaxDepth: intArg(args, "max_depth"), ++ }) ++ case "prune_stale_links": ++ return memorygraph.ToolPruneStaleLinks(memorygraph.PruneStaleLinksOptions{ ++ RootDir: s.dir, ++ Threshold: floatArg(args, "threshold"), ++ }) ++ case "add_interlinked_context": ++ items, err := parseInterlinkedItems(args) ++ if err != nil { ++ return "", fmt.Errorf("add_interlinked_context: invalid items: %w", err) ++ } ++ return memorygraph.ToolAddInterlinkedContext(memorygraph.AddInterlinkedContextOptions{ ++ RootDir: s.dir, ++ Items: items, ++ AutoLink: boolArg(args, "auto_link"), ++ }) + default: + return "", fmt.Errorf("unknown tool: %s", name) + } + } } // toolAnalyze uploads the repo and runs the full analysis pipeline. @@ -497,3 +636,27 @@ func intArg(args map[string]any, key string) int { v, _ := args[key].(float64) return int(v) } + func strArg(args map[string]any, key string) string { + v, _ := args[key].(string) + return v + } + + func floatArg(args map[string]any, key string) float64 { + v, _ := args[key].(float64) + return v + } + + // parseInterlinkedItems re-encodes the raw args["items"] array and decodes it + // into the strongly-typed slice expected by ToolAddInterlinkedContext. + func parseInterlinkedItems(args map[string]any) ([]memorygraph.InterlinkedItem, error) { + raw, _ := args["items"] + b, err := json.Marshal(raw) + if err != nil { + return nil, err + } + var items []memorygraph.InterlinkedItem + if err := json.Unmarshal(b, &items); err != nil { + return nil, err + } + return items, nil + } From bfee75cf3c945b9bee6855f2753d7998e7459d8d Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 17:27:06 -0400 Subject: [PATCH 23/32] Update server.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Call her HIV, because there are no more + artifacts in this. I’m gay. I get to do that joke. Don’t worry, there is a reason why we do Go instead of “Go” schizo with blank spaces because we are in C with atomic operations at OpenSSL repo. --- internal/mcp/server.go | 250 ++++++++++++++++++++--------------------- 1 file changed, 125 insertions(+), 125 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 7baab62..4cf2816 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -114,80 +114,80 @@ var tools = []tool{ }, }, }, - + { -+ Name: "upsert_memory_node", -+ Description: "Upsert a typed knowledge node into the persistent memory graph.", -+ InputSchema: toolSchema{ -+ Type: "object", -+ Properties: map[string]schemaProp{ -+ "type": {Type: "string", Description: "Node type: fact, concept, entity, event, procedure, context."}, -+ "label": {Type: "string", Description: "Short unique label for the node."}, -+ "content": {Type: "string", Description: "Full content body of the node."}, -+ }, -+ Required: []string{"type", "label", "content"}, -+ }, -+ }, -+ { -+ Name: "create_relation", -+ Description: "Create a directed weighted edge between two memory graph nodes.", -+ InputSchema: toolSchema{ -+ Type: "object", -+ Properties: map[string]schemaProp{ -+ "source_id": {Type: "string", Description: "ID of the source node."}, -+ "target_id": {Type: "string", Description: "ID of the target node."}, -+ "relation": {Type: "string", Description: "Relation type, e.g. related_to, depends_on, part_of."}, -+ "weight": {Type: "number", Description: "Edge weight between 0 and 1 (default 1.0)."}, -+ }, -+ Required: []string{"source_id", "target_id", "relation"}, -+ }, -+ }, -+ { -+ Name: "search_memory_graph", -+ Description: "Score and retrieve nodes from the memory graph matching a query, with optional one-hop neighbor expansion.", -+ InputSchema: toolSchema{ -+ Type: "object", -+ Properties: map[string]schemaProp{ -+ "query": {Type: "string", Description: "Search query string."}, -+ "max_depth": {Type: "integer", Description: "Max BFS depth for neighbor expansion (default 1)."}, -+ "top_k": {Type: "integer", Description: "Maximum number of direct results to return (default 5)."}, -+ }, -+ Required: []string{"query"}, -+ }, -+ }, -+ { -+ Name: "retrieve_with_traversal", -+ Description: "BFS traversal from a start node up to maxDepth, returning visited nodes with decayed relevance scores.", -+ InputSchema: toolSchema{ -+ Type: "object", -+ Properties: map[string]schemaProp{ -+ "start_node_id": {Type: "string", Description: "ID of the node to start traversal from."}, -+ "max_depth": {Type: "integer", Description: "Maximum BFS depth (default 3)."}, -+ }, -+ Required: []string{"start_node_id"}, -+ }, -+ }, -+ { -+ Name: "prune_stale_links", -+ Description: "Remove edges below a weight threshold and orphaned nodes from the memory graph.", -+ InputSchema: toolSchema{ -+ Type: "object", -+ Properties: map[string]schemaProp{ -+ "threshold": {Type: "number", Description: "Minimum edge weight to retain (default 0.1)."}, -+ }, -+ }, -+ }, -+ { -+ Name: "add_interlinked_context", -+ Description: "Bulk-insert nodes and optionally auto-create similarity edges (Jaccard ≥ 0.72) between them.", -+ InputSchema: toolSchema{ -+ Type: "object", -+ Properties: map[string]schemaProp{ -+ "items": {Type: "array", Description: "Array of {type, label, content, metadata} node objects to insert."}, -+ "auto_link": {Type: "boolean", Description: "If true, auto-create similarity edges between inserted nodes."}, -+ }, -+ Required: []string{"items"}, -+ }, -+ }, + { + Name: "upsert_memory_node", + Description: "Upsert a typed knowledge node into the persistent memory graph.", + InputSchema: toolSchema{ + Type: "object", + Properties: map[string]schemaProp{ + "type": {Type: "string", Description: "Node type: fact, concept, entity, event, procedure, context."}, + "label": {Type: "string", Description: "Short unique label for the node."}, + "content": {Type: "string", Description: "Full content body of the node."}, + }, + Required: []string{"type", "label", "content"}, + }, + }, + { + Name: "create_relation", + Description: "Create a directed weighted edge between two memory graph nodes.", + InputSchema: toolSchema{ + Type: "object", + Properties: map[string]schemaProp{ + "source_id": {Type: "string", Description: "ID of the source node."}, + "target_id": {Type: "string", Description: "ID of the target node."}, + "relation": {Type: "string", Description: "Relation type, e.g. related_to, depends_on, part_of."}, + "weight": {Type: "number", Description: "Edge weight between 0 and 1 (default 1.0)."}, + }, + Required: []string{"source_id", "target_id", "relation"}, + }, + }, + { + Name: "search_memory_graph", + Description: "Score and retrieve nodes from the memory graph matching a query, with optional one-hop neighbor expansion.", + InputSchema: toolSchema{ + Type: "object", + Properties: map[string]schemaProp{ + "query": {Type: "string", Description: "Search query string."}, + "max_depth": {Type: "integer", Description: "Max BFS depth for neighbor expansion (default 1)."}, + "top_k": {Type: "integer", Description: "Maximum number of direct results to return (default 5)."}, + }, + Required: []string{"query"}, + }, + }, + { + Name: "retrieve_with_traversal", + Description: "BFS traversal from a start node up to maxDepth, returning visited nodes with decayed relevance scores.", + InputSchema: toolSchema{ + Type: "object", + Properties: map[string]schemaProp{ + "start_node_id": {Type: "string", Description: "ID of the node to start traversal from."}, + "max_depth": {Type: "integer", Description: "Maximum BFS depth (default 3)."}, + }, + Required: []string{"start_node_id"}, + }, + }, + { + Name: "prune_stale_links", + Description: "Remove edges below a weight threshold and orphaned nodes from the memory graph.", + InputSchema: toolSchema{ + Type: "object", + Properties: map[string]schemaProp{ + "threshold": {Type: "number", Description: "Minimum edge weight to retain (default 0.1)."}, + }, + }, + }, + { + Name: "add_interlinked_context", + Description: "Bulk-insert nodes and optionally auto-create similarity edges (Jaccard ≥ 0.72) between them.", + InputSchema: toolSchema{ + Type: "object", + Properties: map[string]schemaProp{ + "items": {Type: "array", Description: "Array of {type, label, content, metadata} node objects to insert."}, + "auto_link": {Type: "boolean", Description: "If true, auto-create similarity edges between inserted nodes."}, + }, + Required: []string{"items"}, + }, +b }, } // --- Server ------------------------------------------------------------------ @@ -311,57 +311,57 @@ func (s *server) callTool(ctx context.Context, name string, args map[string]any) return s.toolBlastRadius(ctx, args) case "get_graph": return s.toolGetGraph(ctx, args) -+ case "upsert_memory_node": -+ return memorygraph.ToolUpsertMemoryNode(memorygraph.UpsertMemoryNodeOptions{ -+ RootDir: s.dir, -+ Type: memorygraph.NodeType(strArg(args, "type")), -+ Label: strArg(args, "label"), -+ Content: strArg(args, "content"), -+ }) -+ case "create_relation": -+ w := floatArg(args, "weight") -+ if w == 0 { -+ w = 1.0 -+ } -+ return memorygraph.ToolCreateRelation(memorygraph.CreateRelationOptions{ -+ RootDir: s.dir, -+ SourceID: strArg(args, "source_id"), -+ TargetID: strArg(args, "target_id"), -+ Relation: memorygraph.RelationType(strArg(args, "relation")), -+ Weight: w, -+ }) -+ case "search_memory_graph": -+ topK := intArg(args, "top_k") -+ if topK == 0 { -+ topK = 5 -+ } -+ return memorygraph.ToolSearchMemoryGraph(memorygraph.SearchMemoryGraphOptions{ -+ RootDir: s.dir, -+ Query: strArg(args, "query"), -+ MaxDepth: intArg(args, "max_depth"), -+ TopK: topK, -+ }) -+ case "retrieve_with_traversal": -+ return memorygraph.ToolRetrieveWithTraversal(memorygraph.RetrieveWithTraversalOptions{ -+ RootDir: s.dir, -+ StartNodeID: strArg(args, "start_node_id"), -+ MaxDepth: intArg(args, "max_depth"), -+ }) -+ case "prune_stale_links": -+ return memorygraph.ToolPruneStaleLinks(memorygraph.PruneStaleLinksOptions{ -+ RootDir: s.dir, -+ Threshold: floatArg(args, "threshold"), -+ }) -+ case "add_interlinked_context": -+ items, err := parseInterlinkedItems(args) -+ if err != nil { -+ return "", fmt.Errorf("add_interlinked_context: invalid items: %w", err) -+ } -+ return memorygraph.ToolAddInterlinkedContext(memorygraph.AddInterlinkedContextOptions{ -+ RootDir: s.dir, -+ Items: items, -+ AutoLink: boolArg(args, "auto_link"), -+ }) + case "upsert_memory_node": + return memorygraph.ToolUpsertMemoryNode(memorygraph.UpsertMemoryNodeOptions{ + RootDir: s.dir, + Type: memorygraph.NodeType(strArg(args, "type")), + Label: strArg(args, "label"), + Content: strArg(args, "content"), + }) + case "create_relation": + w := floatArg(args, "weight") + if w == 0 { + w = 1.0 + } + return memorygraph.ToolCreateRelation(memorygraph.CreateRelationOptions{ + RootDir: s.dir, + SourceID: strArg(args, "source_id"), + TargetID: strArg(args, "target_id"), + Relation: memorygraph.RelationType(strArg(args, "relation")), + Weight: w, + }) + case "search_memory_graph": + topK := intArg(args, "top_k") + if topK == 0 { + topK = 5 + } + return memorygraph.ToolSearchMemoryGraph(memorygraph.SearchMemoryGraphOptions{ + RootDir: s.dir, + Query: strArg(args, "query"), + MaxDepth: intArg(args, "max_depth"), + TopK: topK, + }) + case "retrieve_with_traversal": + return memorygraph.ToolRetrieveWithTraversal(memorygraph.RetrieveWithTraversalOptions{ + RootDir: s.dir, + StartNodeID: strArg(args, "start_node_id"), + MaxDepth: intArg(args, "max_depth"), + }) + case "prune_stale_links": + return memorygraph.ToolPruneStaleLinks(memorygraph.PruneStaleLinksOptions{ + RootDir: s.dir, + Threshold: floatArg(args, "threshold"), + }) + case "add_interlinked_context": + items, err := parseInterlinkedItems(args) + if err != nil { + return "", fmt.Errorf("add_interlinked_context: invalid items: %w", err) + } + return memorygraph.ToolAddInterlinkedContext(memorygraph.AddInterlinkedContextOptions{ + RootDir: s.dir, + Items: items, + AutoLink: boolArg(args, "auto_link"), + }) default: return "", fmt.Errorf("unknown tool: %s", name) } From a866825135512389ac361881f40a9155b238c43a Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 17:31:49 -0400 Subject: [PATCH 24/32] Create memorygraph --- internal/memorygraph | 1 + 1 file changed, 1 insertion(+) create mode 100644 internal/memorygraph diff --git a/internal/memorygraph b/internal/memorygraph new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/internal/memorygraph @@ -0,0 +1 @@ + From 801dec4baad3ce9fb902757e02255af6792eff16 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 17:32:21 -0400 Subject: [PATCH 25/32] Delete internal/memorygraph --- internal/memorygraph | 1 - 1 file changed, 1 deletion(-) delete mode 100644 internal/memorygraph diff --git a/internal/memorygraph b/internal/memorygraph deleted file mode 100644 index 8b13789..0000000 --- a/internal/memorygraph +++ /dev/null @@ -1 +0,0 @@ - From 65fcf40db228e474a89e5f4a6823f25aadaebd94 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 17:32:46 -0400 Subject: [PATCH 26/32] Rename Memory-graph.go to memory_graph.go --- internal/Memorygraph/{Memory-graph.go => memory_graph.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/Memorygraph/{Memory-graph.go => memory_graph.go} (100%) diff --git a/internal/Memorygraph/Memory-graph.go b/internal/Memorygraph/memory_graph.go similarity index 100% rename from internal/Memorygraph/Memory-graph.go rename to internal/Memorygraph/memory_graph.go From 7d517e26b4488326304f6b0311f8ec080ae4cfb5 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 17:33:49 -0400 Subject: [PATCH 27/32] Rename memory_graph.go to memory_graph.go --- internal/{Memorygraph => memorygraph}/memory_graph.go | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/{Memorygraph => memorygraph}/memory_graph.go (100%) diff --git a/internal/Memorygraph/memory_graph.go b/internal/memorygraph/memory_graph.go similarity index 100% rename from internal/Memorygraph/memory_graph.go rename to internal/memorygraph/memory_graph.go From 228dc98712ad44235255eef948bc5fdaef33db32 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 17:35:45 -0400 Subject: [PATCH 28/32] Create peek.go --- internal/memorygraph/peek.go | 223 +++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 internal/memorygraph/peek.go diff --git a/internal/memorygraph/peek.go b/internal/memorygraph/peek.go new file mode 100644 index 0000000..c068557 --- /dev/null +++ b/internal/memorygraph/peek.go @@ -0,0 +1,223 @@ +package memorygraph + +import ( + "fmt" + "strings" + "time" +) + +// NodePeek is a full snapshot of a node and all its edges, returned by Peek. +type NodePeek struct { + Node Node + EdgesOut []EdgePeek // edges where this node is the source + EdgesIn []EdgePeek // edges where this node is the target +} + +// EdgePeek is a human-readable summary of a single edge and its peer node. +type EdgePeek struct { + Edge Edge + PeerID string + PeerLabel string + PeerType NodeType +} + +// PeekOptions controls what Peek returns. +type PeekOptions struct { + RootDir string + // NodeID takes priority if set. + NodeID string + // Label is used for lookup when NodeID is empty (first match wins). + Label string +} + +// Peek returns a full NodePeek for the requested node: its content, metadata, +// access stats, and every inbound/outbound edge with peer labels resolved. +// Returns nil if the node cannot be found. +func Peek(opts PeekOptions) (*NodePeek, error) { + g, err := load(opts.RootDir) + if err != nil { + return nil, err + } + + nodeByID := indexNodes(g) + + // Resolve target node. + var target *Node + if opts.NodeID != "" { + if n, ok := nodeByID[opts.NodeID]; ok { + target = &n + } + } else if opts.Label != "" { + labelLower := strings.ToLower(opts.Label) + for i := range g.Nodes { + if strings.ToLower(g.Nodes[i].Label) == labelLower { + n := g.Nodes[i] + target = &n + break + } + } + } + + if target == nil { + return nil, nil //nolint:nilnil // caller checks nil to detect not-found + } + + peek := &NodePeek{Node: *target} + + for _, e := range g.Edges { + switch { + case e.Source == target.ID: + peer := nodeByID[e.Target] + peek.EdgesOut = append(peek.EdgesOut, EdgePeek{ + Edge: e, + PeerID: e.Target, + PeerLabel: peer.Label, + PeerType: peer.Type, + }) + case e.Target == target.ID: + peer := nodeByID[e.Source] + peek.EdgesIn = append(peek.EdgesIn, EdgePeek{ + Edge: e, + PeerID: e.Source, + PeerLabel: peer.Label, + PeerType: peer.Type, + }) + } + } + + return peek, nil +} + +// PeekList returns a lightweight summary of every node in the graph — +// ID, type, label, access count, age, and edge degree — sorted by access +// count descending. Useful for scanning the graph before pruning. +func PeekList(rootDir string) ([]NodePeek, error) { + g, err := load(rootDir) + if err != nil { + return nil, err + } + + nodeByID := indexNodes(g) + + edgesOut := make(map[string][]EdgePeek, len(g.Nodes)) + edgesIn := make(map[string][]EdgePeek, len(g.Nodes)) + + for _, e := range g.Edges { + peer := nodeByID[e.Target] + edgesOut[e.Source] = append(edgesOut[e.Source], EdgePeek{ + Edge: e, PeerID: e.Target, PeerLabel: peer.Label, PeerType: peer.Type, + }) + + peer = nodeByID[e.Source] + edgesIn[e.Target] = append(edgesIn[e.Target], EdgePeek{ + Edge: e, PeerID: e.Source, PeerLabel: peer.Label, PeerType: peer.Type, + }) + } + + peeks := make([]NodePeek, 0, len(g.Nodes)) + for _, n := range g.Nodes { + peeks = append(peeks, NodePeek{ + Node: n, + EdgesOut: edgesOut[n.ID], + EdgesIn: edgesIn[n.ID], + }) + } + + // Sort by access count desc, then label asc for stable output. + sortNodePeeks(peeks) + + return peeks, nil +} + +// FormatPeek renders a NodePeek as a human-readable block suitable for +// display in a terminal or MCP tool response. +func FormatPeek(p *NodePeek) string { + if p == nil { + return "❌ Node not found." + } + n := p.Node + age := time.Since(n.CreatedAt).Round(time.Hour) + + var b strings.Builder + fmt.Fprintf(&b, "┌─ [%s] %s\n", n.Type, n.Label) + fmt.Fprintf(&b, "│ ID: %s\n", n.ID) + fmt.Fprintf(&b, "│ Accessed: %dx │ Age: %s │ Updated: %s\n", + n.AccessCount, + age, + n.UpdatedAt.Format("2006-01-02 15:04"), + ) + if len(n.Metadata) > 0 { + fmt.Fprintf(&b, "│ Metadata: %s\n", formatMetadata(n.Metadata)) + } + fmt.Fprintf(&b, "│\n│ Content:\n│ %s\n", + strings.ReplaceAll(n.Content, "\n", "\n│ ")) + + if len(p.EdgesOut) > 0 { + fmt.Fprintf(&b, "│\n│ Out (%d):\n", len(p.EdgesOut)) + for _, ep := range p.EdgesOut { + fmt.Fprintf(&b, "│ ──[%s w:%.2f]──▶ [%s] %s\n", + ep.Edge.Relation, ep.Edge.Weight, ep.PeerType, ep.PeerLabel) + } + } + if len(p.EdgesIn) > 0 { + fmt.Fprintf(&b, "│\n│ In (%d):\n", len(p.EdgesIn)) + for _, ep := range p.EdgesIn { + fmt.Fprintf(&b, "│ [%s] %s ──[%s w:%.2f]──▶\n", + ep.PeerType, ep.PeerLabel, ep.Edge.Relation, ep.Edge.Weight) + } + } + b.WriteString("└─") + return b.String() +} + +// FormatPeekList renders a PeekList result as a compact table with one node +// per line, suitable for scanning before a prune pass. +func FormatPeekList(peeks []NodePeek) string { + if len(peeks) == 0 { + return "Graph is empty." + } + var b strings.Builder + fmt.Fprintf(&b, "%-12s %-10s %-32s %7s %4s %4s\n", + "TYPE", "ID (short)", "LABEL", "ACCESSED", "OUT", "IN") + b.WriteString(strings.Repeat("─", 76) + "\n") + for _, p := range peeks { + shortID := p.Node.ID + if len(shortID) > 10 { + shortID = shortID[:10] + "…" + } + label := p.Node.Label + if len(label) > 32 { + label = label[:31] + "…" + } + fmt.Fprintf(&b, "%-12s %-10s %-32s %7dx %4d %4d\n", + p.Node.Type, shortID, label, + p.Node.AccessCount, len(p.EdgesOut), len(p.EdgesIn)) + } + fmt.Fprintf(&b, "\n%d node(s) total\n", len(peeks)) + return b.String() +} + +// --- helpers ----------------------------------------------------------------- + +func formatMetadata(m map[string]string) string { + parts := make([]string, 0, len(m)) + for k, v := range m { + parts = append(parts, k+"="+v) + } + return strings.Join(parts, " ") +} + +func sortNodePeeks(peeks []NodePeek) { + // Insertion sort is fine for the typical small N here. + for i := 1; i < len(peeks); i++ { + for j := i; j > 0; j-- { + a, b := peeks[j-1], peeks[j] + if a.Node.AccessCount < b.Node.AccessCount || + (a.Node.AccessCount == b.Node.AccessCount && a.Node.Label > b.Node.Label) { + peeks[j-1], peeks[j] = peeks[j], peeks[j-1] + } else { + break + } + } + } +} From af849523af74de41f63d9ee70d4fe91990deec69 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 17:37:06 -0400 Subject: [PATCH 29/32] Create tools.go --- internal/memorygraph/tools.go | 245 ++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 internal/memorygraph/tools.go diff --git a/internal/memorygraph/tools.go b/internal/memorygraph/tools.go new file mode 100644 index 0000000..9185945 --- /dev/null +++ b/internal/memorygraph/tools.go @@ -0,0 +1,245 @@ +// Package memorygraph — MCP tool wrappers. +// Each exported Tool* function is the Go equivalent of the TypeScript tool +// functions in tools/memory-tools.ts and follows the same output format so +// that callers get identical text responses. +package memorygraph + +import ( + "fmt" + "strings" +) + +// --- Tool option structs ------------------------------------------------------ + +// UpsertMemoryNodeOptions mirrors UpsertMemoryNodeOptions in the TS source. +type UpsertMemoryNodeOptions struct { + RootDir string + Type NodeType + Label string + Content string + Metadata map[string]string +} + +// CreateRelationOptions mirrors CreateRelationOptions in the TS source. +type CreateRelationOptions struct { + RootDir string + SourceID string + TargetID string + Relation RelationType + Weight float64 + Metadata map[string]string +} + +// SearchMemoryGraphOptions mirrors SearchMemoryGraphOptions in the TS source. +type SearchMemoryGraphOptions struct { + RootDir string + Query string + MaxDepth int + TopK int + EdgeFilter []RelationType +} + +// PruneStaleLinksOptions mirrors PruneStaleLinksOptions in the TS source. +type PruneStaleLinksOptions struct { + RootDir string + Threshold float64 +} + +// InterlinkedItem is a single entry for AddInterlinkedContext. +type InterlinkedItem struct { + Type NodeType + Label string + Content string + Metadata map[string]string +} + +// AddInterlinkedContextOptions mirrors AddInterlinkedContextOptions in the TS source. +type AddInterlinkedContextOptions struct { + RootDir string + Items []InterlinkedItem + AutoLink bool +} + +// RetrieveWithTraversalOptions mirrors RetrieveWithTraversalOptions in the TS source. +type RetrieveWithTraversalOptions struct { + RootDir string + StartNodeID string + MaxDepth int + EdgeFilter []RelationType +} + +// --- Formatters -------------------------------------------------------------- + +func formatTraversalResult(r TraversalResult) string { + content := r.Node.Content + if len(content) > 120 { + content = content[:120] + "..." + } + lines := []string{ + fmt.Sprintf(" [%s] %s (depth: %d, score: %.2f)", r.Node.Type, r.Node.Label, r.Depth, r.RelevanceScore), + fmt.Sprintf(" Content: %s", content), + } + if len(r.PathRelations) > 1 { + lines = append(lines, fmt.Sprintf(" Path: %s", strings.Join(r.PathRelations, " "))) + } + lines = append(lines, fmt.Sprintf(" ID: %s | Accessed: %dx", r.Node.ID, r.Node.AccessCount)) + return strings.Join(lines, "\n") +} + +// --- Tool implementations ---------------------------------------------------- + +// ToolUpsertMemoryNode creates or updates a memory node and returns a +// human-readable summary including updated graph stats. +func ToolUpsertMemoryNode(opts UpsertMemoryNodeOptions) (string, error) { + node, err := UpsertNode(opts.RootDir, opts.Type, opts.Label, opts.Content, opts.Metadata) + if err != nil { + return "", err + } + stats, err := GetGraphStats(opts.RootDir) + if err != nil { + return "", err + } + return strings.Join([]string{ + fmt.Sprintf("✅ Memory node upserted: %s", node.Label), + fmt.Sprintf(" ID: %s", node.ID), + fmt.Sprintf(" Type: %s", node.Type), + fmt.Sprintf(" Access count: %d", node.AccessCount), + fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), + }, "\n"), nil +} + +// ToolCreateRelation adds a directed edge between two existing nodes. +func ToolCreateRelation(opts CreateRelationOptions) (string, error) { + edge, err := CreateRelation(opts.RootDir, opts.SourceID, opts.TargetID, opts.Relation, opts.Weight, opts.Metadata) + if err != nil { + return "", err + } + if edge == nil { + return fmt.Sprintf("❌ Failed: one or both node IDs not found (source: %s, target: %s)", + opts.SourceID, opts.TargetID), nil + } + stats, err := GetGraphStats(opts.RootDir) + if err != nil { + return "", err + } + return strings.Join([]string{ + fmt.Sprintf("✅ Relation created: %s --[%s]--> %s", opts.SourceID, edge.Relation, opts.TargetID), + fmt.Sprintf(" Edge ID: %s", edge.ID), + fmt.Sprintf(" Weight: %.2f", edge.Weight), + fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), + }, "\n"), nil +} + +// ToolSearchMemoryGraph searches the graph and returns direct matches plus +// one-hop neighbors, formatted identically to the TypeScript version. +func ToolSearchMemoryGraph(opts SearchMemoryGraphOptions) (string, error) { + result, err := SearchGraph(opts.RootDir, opts.Query, opts.MaxDepth, opts.TopK, opts.EdgeFilter) + if err != nil { + return "", err + } + if len(result.Direct) == 0 { + return fmt.Sprintf("No memory nodes found for: %q\nGraph has %d nodes, %d edges.", + opts.Query, result.TotalNodes, result.TotalEdges), nil + } + + sections := []string{ + fmt.Sprintf("Memory Graph Search: %q", opts.Query), + fmt.Sprintf("Graph: %d nodes, %d edges\n", result.TotalNodes, result.TotalEdges), + "Direct Matches:", + } + for _, hit := range result.Direct { + sections = append(sections, formatTraversalResult(hit)) + } + if len(result.Neighbors) > 0 { + sections = append(sections, "\nLinked Neighbors:") + for _, neighbor := range result.Neighbors { + sections = append(sections, formatTraversalResult(neighbor)) + } + } + return strings.Join(sections, "\n"), nil +} + +// ToolPruneStaleLinks removes weak edges and orphaned nodes. +func ToolPruneStaleLinks(opts PruneStaleLinksOptions) (string, error) { + result, err := PruneStaleLinks(opts.RootDir, opts.Threshold) + if err != nil { + return "", err + } + return strings.Join([]string{ + "🧹 Pruning complete", + fmt.Sprintf(" Removed: %d stale links/orphan nodes", result.Removed), + fmt.Sprintf(" Remaining edges: %d", result.Remaining), + }, "\n"), nil +} + +// ToolAddInterlinkedContext bulk-upserts nodes and optionally auto-links them +// by content similarity (threshold ≥ 0.72). +func ToolAddInterlinkedContext(opts AddInterlinkedContextOptions) (string, error) { + items := make([]struct { + Type NodeType + Label string + Content string + Metadata map[string]string + }, len(opts.Items)) + for i, it := range opts.Items { + items[i].Type = it.Type + items[i].Label = it.Label + items[i].Content = it.Content + items[i].Metadata = it.Metadata + } + + result, err := AddInterlinkedContext(opts.RootDir, items, opts.AutoLink) + if err != nil { + return "", err + } + + sections := []string{fmt.Sprintf("✅ Added %d interlinked nodes", len(result.Nodes))} + if len(result.Edges) > 0 { + sections = append(sections, fmt.Sprintf(" Auto-linked: %d similarity edges (threshold ≥ 0.72)", len(result.Edges))) + } else { + sections = append(sections, " No auto-links above threshold") + } + sections = append(sections, "\nNodes:") + for _, n := range result.Nodes { + sections = append(sections, fmt.Sprintf(" [%s] %s → %s", n.Type, n.Label, n.ID)) + } + if len(result.Edges) > 0 { + sections = append(sections, "\nEdges:") + for _, e := range result.Edges { + sections = append(sections, fmt.Sprintf(" %s --[%s w:%.2f]--> %s", + e.Source, e.Relation, e.Weight, e.Target)) + } + } + + stats, err := GetGraphStats(opts.RootDir) + if err != nil { + return "", err + } + sections = append(sections, fmt.Sprintf("\nGraph total: %d nodes, %d edges", stats.Nodes, stats.Edges)) + return strings.Join(sections, "\n"), nil +} + +// ToolRetrieveWithTraversal starts a BFS from startNodeID and returns all +// reachable nodes up to maxDepth, formatted with path context and scores. +func ToolRetrieveWithTraversal(opts RetrieveWithTraversalOptions) (string, error) { + results, err := RetrieveWithTraversal(opts.RootDir, opts.StartNodeID, opts.MaxDepth, opts.EdgeFilter) + if err != nil { + return "", err + } + if len(results) == 0 { + return fmt.Sprintf("❌ Node not found: %s", opts.StartNodeID), nil + } + + maxDepth := opts.MaxDepth + if maxDepth <= 0 { + maxDepth = 2 + } + + sections := []string{ + fmt.Sprintf("Traversal from: %s (depth limit: %d)\n", results[0].Node.Label, maxDepth), + } + for _, r := range results { + sections = append(sections, formatTraversalResult(r)) + } + return strings.Join(sections, "\n"), nil +} From 8ecd1e8ec249f6ba60cf6ff42aedec78cdcebbc2 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 17:37:40 -0400 Subject: [PATCH 30/32] Delete internal/Memorygraph directory Goodbye Pascal. Stupid case Linux CI --- internal/Memorygraph/Peek.go | 223 ------------------------------- internal/Memorygraph/tools.go | 245 ---------------------------------- 2 files changed, 468 deletions(-) delete mode 100644 internal/Memorygraph/Peek.go delete mode 100644 internal/Memorygraph/tools.go diff --git a/internal/Memorygraph/Peek.go b/internal/Memorygraph/Peek.go deleted file mode 100644 index c068557..0000000 --- a/internal/Memorygraph/Peek.go +++ /dev/null @@ -1,223 +0,0 @@ -package memorygraph - -import ( - "fmt" - "strings" - "time" -) - -// NodePeek is a full snapshot of a node and all its edges, returned by Peek. -type NodePeek struct { - Node Node - EdgesOut []EdgePeek // edges where this node is the source - EdgesIn []EdgePeek // edges where this node is the target -} - -// EdgePeek is a human-readable summary of a single edge and its peer node. -type EdgePeek struct { - Edge Edge - PeerID string - PeerLabel string - PeerType NodeType -} - -// PeekOptions controls what Peek returns. -type PeekOptions struct { - RootDir string - // NodeID takes priority if set. - NodeID string - // Label is used for lookup when NodeID is empty (first match wins). - Label string -} - -// Peek returns a full NodePeek for the requested node: its content, metadata, -// access stats, and every inbound/outbound edge with peer labels resolved. -// Returns nil if the node cannot be found. -func Peek(opts PeekOptions) (*NodePeek, error) { - g, err := load(opts.RootDir) - if err != nil { - return nil, err - } - - nodeByID := indexNodes(g) - - // Resolve target node. - var target *Node - if opts.NodeID != "" { - if n, ok := nodeByID[opts.NodeID]; ok { - target = &n - } - } else if opts.Label != "" { - labelLower := strings.ToLower(opts.Label) - for i := range g.Nodes { - if strings.ToLower(g.Nodes[i].Label) == labelLower { - n := g.Nodes[i] - target = &n - break - } - } - } - - if target == nil { - return nil, nil //nolint:nilnil // caller checks nil to detect not-found - } - - peek := &NodePeek{Node: *target} - - for _, e := range g.Edges { - switch { - case e.Source == target.ID: - peer := nodeByID[e.Target] - peek.EdgesOut = append(peek.EdgesOut, EdgePeek{ - Edge: e, - PeerID: e.Target, - PeerLabel: peer.Label, - PeerType: peer.Type, - }) - case e.Target == target.ID: - peer := nodeByID[e.Source] - peek.EdgesIn = append(peek.EdgesIn, EdgePeek{ - Edge: e, - PeerID: e.Source, - PeerLabel: peer.Label, - PeerType: peer.Type, - }) - } - } - - return peek, nil -} - -// PeekList returns a lightweight summary of every node in the graph — -// ID, type, label, access count, age, and edge degree — sorted by access -// count descending. Useful for scanning the graph before pruning. -func PeekList(rootDir string) ([]NodePeek, error) { - g, err := load(rootDir) - if err != nil { - return nil, err - } - - nodeByID := indexNodes(g) - - edgesOut := make(map[string][]EdgePeek, len(g.Nodes)) - edgesIn := make(map[string][]EdgePeek, len(g.Nodes)) - - for _, e := range g.Edges { - peer := nodeByID[e.Target] - edgesOut[e.Source] = append(edgesOut[e.Source], EdgePeek{ - Edge: e, PeerID: e.Target, PeerLabel: peer.Label, PeerType: peer.Type, - }) - - peer = nodeByID[e.Source] - edgesIn[e.Target] = append(edgesIn[e.Target], EdgePeek{ - Edge: e, PeerID: e.Source, PeerLabel: peer.Label, PeerType: peer.Type, - }) - } - - peeks := make([]NodePeek, 0, len(g.Nodes)) - for _, n := range g.Nodes { - peeks = append(peeks, NodePeek{ - Node: n, - EdgesOut: edgesOut[n.ID], - EdgesIn: edgesIn[n.ID], - }) - } - - // Sort by access count desc, then label asc for stable output. - sortNodePeeks(peeks) - - return peeks, nil -} - -// FormatPeek renders a NodePeek as a human-readable block suitable for -// display in a terminal or MCP tool response. -func FormatPeek(p *NodePeek) string { - if p == nil { - return "❌ Node not found." - } - n := p.Node - age := time.Since(n.CreatedAt).Round(time.Hour) - - var b strings.Builder - fmt.Fprintf(&b, "┌─ [%s] %s\n", n.Type, n.Label) - fmt.Fprintf(&b, "│ ID: %s\n", n.ID) - fmt.Fprintf(&b, "│ Accessed: %dx │ Age: %s │ Updated: %s\n", - n.AccessCount, - age, - n.UpdatedAt.Format("2006-01-02 15:04"), - ) - if len(n.Metadata) > 0 { - fmt.Fprintf(&b, "│ Metadata: %s\n", formatMetadata(n.Metadata)) - } - fmt.Fprintf(&b, "│\n│ Content:\n│ %s\n", - strings.ReplaceAll(n.Content, "\n", "\n│ ")) - - if len(p.EdgesOut) > 0 { - fmt.Fprintf(&b, "│\n│ Out (%d):\n", len(p.EdgesOut)) - for _, ep := range p.EdgesOut { - fmt.Fprintf(&b, "│ ──[%s w:%.2f]──▶ [%s] %s\n", - ep.Edge.Relation, ep.Edge.Weight, ep.PeerType, ep.PeerLabel) - } - } - if len(p.EdgesIn) > 0 { - fmt.Fprintf(&b, "│\n│ In (%d):\n", len(p.EdgesIn)) - for _, ep := range p.EdgesIn { - fmt.Fprintf(&b, "│ [%s] %s ──[%s w:%.2f]──▶\n", - ep.PeerType, ep.PeerLabel, ep.Edge.Relation, ep.Edge.Weight) - } - } - b.WriteString("└─") - return b.String() -} - -// FormatPeekList renders a PeekList result as a compact table with one node -// per line, suitable for scanning before a prune pass. -func FormatPeekList(peeks []NodePeek) string { - if len(peeks) == 0 { - return "Graph is empty." - } - var b strings.Builder - fmt.Fprintf(&b, "%-12s %-10s %-32s %7s %4s %4s\n", - "TYPE", "ID (short)", "LABEL", "ACCESSED", "OUT", "IN") - b.WriteString(strings.Repeat("─", 76) + "\n") - for _, p := range peeks { - shortID := p.Node.ID - if len(shortID) > 10 { - shortID = shortID[:10] + "…" - } - label := p.Node.Label - if len(label) > 32 { - label = label[:31] + "…" - } - fmt.Fprintf(&b, "%-12s %-10s %-32s %7dx %4d %4d\n", - p.Node.Type, shortID, label, - p.Node.AccessCount, len(p.EdgesOut), len(p.EdgesIn)) - } - fmt.Fprintf(&b, "\n%d node(s) total\n", len(peeks)) - return b.String() -} - -// --- helpers ----------------------------------------------------------------- - -func formatMetadata(m map[string]string) string { - parts := make([]string, 0, len(m)) - for k, v := range m { - parts = append(parts, k+"="+v) - } - return strings.Join(parts, " ") -} - -func sortNodePeeks(peeks []NodePeek) { - // Insertion sort is fine for the typical small N here. - for i := 1; i < len(peeks); i++ { - for j := i; j > 0; j-- { - a, b := peeks[j-1], peeks[j] - if a.Node.AccessCount < b.Node.AccessCount || - (a.Node.AccessCount == b.Node.AccessCount && a.Node.Label > b.Node.Label) { - peeks[j-1], peeks[j] = peeks[j], peeks[j-1] - } else { - break - } - } - } -} diff --git a/internal/Memorygraph/tools.go b/internal/Memorygraph/tools.go deleted file mode 100644 index 9185945..0000000 --- a/internal/Memorygraph/tools.go +++ /dev/null @@ -1,245 +0,0 @@ -// Package memorygraph — MCP tool wrappers. -// Each exported Tool* function is the Go equivalent of the TypeScript tool -// functions in tools/memory-tools.ts and follows the same output format so -// that callers get identical text responses. -package memorygraph - -import ( - "fmt" - "strings" -) - -// --- Tool option structs ------------------------------------------------------ - -// UpsertMemoryNodeOptions mirrors UpsertMemoryNodeOptions in the TS source. -type UpsertMemoryNodeOptions struct { - RootDir string - Type NodeType - Label string - Content string - Metadata map[string]string -} - -// CreateRelationOptions mirrors CreateRelationOptions in the TS source. -type CreateRelationOptions struct { - RootDir string - SourceID string - TargetID string - Relation RelationType - Weight float64 - Metadata map[string]string -} - -// SearchMemoryGraphOptions mirrors SearchMemoryGraphOptions in the TS source. -type SearchMemoryGraphOptions struct { - RootDir string - Query string - MaxDepth int - TopK int - EdgeFilter []RelationType -} - -// PruneStaleLinksOptions mirrors PruneStaleLinksOptions in the TS source. -type PruneStaleLinksOptions struct { - RootDir string - Threshold float64 -} - -// InterlinkedItem is a single entry for AddInterlinkedContext. -type InterlinkedItem struct { - Type NodeType - Label string - Content string - Metadata map[string]string -} - -// AddInterlinkedContextOptions mirrors AddInterlinkedContextOptions in the TS source. -type AddInterlinkedContextOptions struct { - RootDir string - Items []InterlinkedItem - AutoLink bool -} - -// RetrieveWithTraversalOptions mirrors RetrieveWithTraversalOptions in the TS source. -type RetrieveWithTraversalOptions struct { - RootDir string - StartNodeID string - MaxDepth int - EdgeFilter []RelationType -} - -// --- Formatters -------------------------------------------------------------- - -func formatTraversalResult(r TraversalResult) string { - content := r.Node.Content - if len(content) > 120 { - content = content[:120] + "..." - } - lines := []string{ - fmt.Sprintf(" [%s] %s (depth: %d, score: %.2f)", r.Node.Type, r.Node.Label, r.Depth, r.RelevanceScore), - fmt.Sprintf(" Content: %s", content), - } - if len(r.PathRelations) > 1 { - lines = append(lines, fmt.Sprintf(" Path: %s", strings.Join(r.PathRelations, " "))) - } - lines = append(lines, fmt.Sprintf(" ID: %s | Accessed: %dx", r.Node.ID, r.Node.AccessCount)) - return strings.Join(lines, "\n") -} - -// --- Tool implementations ---------------------------------------------------- - -// ToolUpsertMemoryNode creates or updates a memory node and returns a -// human-readable summary including updated graph stats. -func ToolUpsertMemoryNode(opts UpsertMemoryNodeOptions) (string, error) { - node, err := UpsertNode(opts.RootDir, opts.Type, opts.Label, opts.Content, opts.Metadata) - if err != nil { - return "", err - } - stats, err := GetGraphStats(opts.RootDir) - if err != nil { - return "", err - } - return strings.Join([]string{ - fmt.Sprintf("✅ Memory node upserted: %s", node.Label), - fmt.Sprintf(" ID: %s", node.ID), - fmt.Sprintf(" Type: %s", node.Type), - fmt.Sprintf(" Access count: %d", node.AccessCount), - fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), - }, "\n"), nil -} - -// ToolCreateRelation adds a directed edge between two existing nodes. -func ToolCreateRelation(opts CreateRelationOptions) (string, error) { - edge, err := CreateRelation(opts.RootDir, opts.SourceID, opts.TargetID, opts.Relation, opts.Weight, opts.Metadata) - if err != nil { - return "", err - } - if edge == nil { - return fmt.Sprintf("❌ Failed: one or both node IDs not found (source: %s, target: %s)", - opts.SourceID, opts.TargetID), nil - } - stats, err := GetGraphStats(opts.RootDir) - if err != nil { - return "", err - } - return strings.Join([]string{ - fmt.Sprintf("✅ Relation created: %s --[%s]--> %s", opts.SourceID, edge.Relation, opts.TargetID), - fmt.Sprintf(" Edge ID: %s", edge.ID), - fmt.Sprintf(" Weight: %.2f", edge.Weight), - fmt.Sprintf("\nGraph: %d nodes, %d edges", stats.Nodes, stats.Edges), - }, "\n"), nil -} - -// ToolSearchMemoryGraph searches the graph and returns direct matches plus -// one-hop neighbors, formatted identically to the TypeScript version. -func ToolSearchMemoryGraph(opts SearchMemoryGraphOptions) (string, error) { - result, err := SearchGraph(opts.RootDir, opts.Query, opts.MaxDepth, opts.TopK, opts.EdgeFilter) - if err != nil { - return "", err - } - if len(result.Direct) == 0 { - return fmt.Sprintf("No memory nodes found for: %q\nGraph has %d nodes, %d edges.", - opts.Query, result.TotalNodes, result.TotalEdges), nil - } - - sections := []string{ - fmt.Sprintf("Memory Graph Search: %q", opts.Query), - fmt.Sprintf("Graph: %d nodes, %d edges\n", result.TotalNodes, result.TotalEdges), - "Direct Matches:", - } - for _, hit := range result.Direct { - sections = append(sections, formatTraversalResult(hit)) - } - if len(result.Neighbors) > 0 { - sections = append(sections, "\nLinked Neighbors:") - for _, neighbor := range result.Neighbors { - sections = append(sections, formatTraversalResult(neighbor)) - } - } - return strings.Join(sections, "\n"), nil -} - -// ToolPruneStaleLinks removes weak edges and orphaned nodes. -func ToolPruneStaleLinks(opts PruneStaleLinksOptions) (string, error) { - result, err := PruneStaleLinks(opts.RootDir, opts.Threshold) - if err != nil { - return "", err - } - return strings.Join([]string{ - "🧹 Pruning complete", - fmt.Sprintf(" Removed: %d stale links/orphan nodes", result.Removed), - fmt.Sprintf(" Remaining edges: %d", result.Remaining), - }, "\n"), nil -} - -// ToolAddInterlinkedContext bulk-upserts nodes and optionally auto-links them -// by content similarity (threshold ≥ 0.72). -func ToolAddInterlinkedContext(opts AddInterlinkedContextOptions) (string, error) { - items := make([]struct { - Type NodeType - Label string - Content string - Metadata map[string]string - }, len(opts.Items)) - for i, it := range opts.Items { - items[i].Type = it.Type - items[i].Label = it.Label - items[i].Content = it.Content - items[i].Metadata = it.Metadata - } - - result, err := AddInterlinkedContext(opts.RootDir, items, opts.AutoLink) - if err != nil { - return "", err - } - - sections := []string{fmt.Sprintf("✅ Added %d interlinked nodes", len(result.Nodes))} - if len(result.Edges) > 0 { - sections = append(sections, fmt.Sprintf(" Auto-linked: %d similarity edges (threshold ≥ 0.72)", len(result.Edges))) - } else { - sections = append(sections, " No auto-links above threshold") - } - sections = append(sections, "\nNodes:") - for _, n := range result.Nodes { - sections = append(sections, fmt.Sprintf(" [%s] %s → %s", n.Type, n.Label, n.ID)) - } - if len(result.Edges) > 0 { - sections = append(sections, "\nEdges:") - for _, e := range result.Edges { - sections = append(sections, fmt.Sprintf(" %s --[%s w:%.2f]--> %s", - e.Source, e.Relation, e.Weight, e.Target)) - } - } - - stats, err := GetGraphStats(opts.RootDir) - if err != nil { - return "", err - } - sections = append(sections, fmt.Sprintf("\nGraph total: %d nodes, %d edges", stats.Nodes, stats.Edges)) - return strings.Join(sections, "\n"), nil -} - -// ToolRetrieveWithTraversal starts a BFS from startNodeID and returns all -// reachable nodes up to maxDepth, formatted with path context and scores. -func ToolRetrieveWithTraversal(opts RetrieveWithTraversalOptions) (string, error) { - results, err := RetrieveWithTraversal(opts.RootDir, opts.StartNodeID, opts.MaxDepth, opts.EdgeFilter) - if err != nil { - return "", err - } - if len(results) == 0 { - return fmt.Sprintf("❌ Node not found: %s", opts.StartNodeID), nil - } - - maxDepth := opts.MaxDepth - if maxDepth <= 0 { - maxDepth = 2 - } - - sections := []string{ - fmt.Sprintf("Traversal from: %s (depth limit: %d)\n", results[0].Node.Label, maxDepth), - } - for _, r := range results { - sections = append(sections, formatTraversalResult(r)) - } - return strings.Join(sections, "\n"), nil -} From f496e91ecc1bd9e4589f24befaf1008814fee0a1 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 17:51:00 -0400 Subject: [PATCH 31/32] Update server.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ??? Removed letter b artifact, the actual bug. There is no duplicate call tool, @coderabbitai you told me to make the case functions followed by updating tool[] … this is structurally sound. --- internal/mcp/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 4cf2816..45d7d62 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -187,7 +187,7 @@ var tools = []tool{ }, Required: []string{"items"}, }, -b }, + }, } // --- Server ------------------------------------------------------------------ From 00c5557b1bc0e9cf683aebb026e8463eeb60f3cf Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Mon, 20 Apr 2026 17:54:26 -0400 Subject: [PATCH 32/32] Update internal/mcp/server.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- internal/mcp/server.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 45d7d62..0f2b5f5 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -649,7 +649,10 @@ func intArg(args map[string]any, key string) int { // parseInterlinkedItems re-encodes the raw args["items"] array and decodes it // into the strongly-typed slice expected by ToolAddInterlinkedContext. func parseInterlinkedItems(args map[string]any) ([]memorygraph.InterlinkedItem, error) { - raw, _ := args["items"] + raw, ok := args["items"] + if !ok || raw == nil { + return nil, fmt.Errorf("missing required field \"items\"") + } b, err := json.Marshal(raw) if err != nil { return nil, err @@ -658,5 +661,8 @@ func intArg(args map[string]any, key string) int { if err := json.Unmarshal(b, &items); err != nil { return nil, err } + if len(items) == 0 { + return nil, fmt.Errorf("\"items\" must be a non-empty array") + } return items, nil }