diff --git a/cmd/synapbus/main.go b/cmd/synapbus/main.go index b0a4219..4ffbbea 100644 --- a/cmd/synapbus/main.go +++ b/cmd/synapbus/main.go @@ -51,6 +51,7 @@ import ( "github.com/synapbus/synapbus/internal/trace" "github.com/synapbus/synapbus/internal/trust" "github.com/synapbus/synapbus/internal/web" + "github.com/synapbus/synapbus/internal/wiki" "github.com/synapbus/synapbus/internal/webhooks" ) @@ -492,7 +493,9 @@ func runServe(cmd *cobra.Command, args []string) error { actionIndex := actions.NewIndex(actionRegistry.List()) // Create MCP server (4 hybrid tools: my_status, send_message, search, execute) - mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, reactionService, trustService, con, jsPool, actionRegistry, actionIndex, db.DB) + wikiService := wiki.NewService(db.DB) + + mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, reactionService, trustService, wikiService, con, jsPool, actionRegistry, actionIndex, db.DB) // Set up SQL query executor for agents (uses read pool if available) queryDB := db.QueryDB() @@ -664,6 +667,7 @@ func runServe(cmd *cobra.Command, args []string) error { ReactorStore: reactorStore, ReactorEngine: reactorEngine, BaseURL: baseURL, + WikiService: wikiService, }) r.Mount("/", apiRouter) diff --git a/internal/actions/registry.go b/internal/actions/registry.go index 9ecda51..c4e1c89 100644 --- a/internal/actions/registry.go +++ b/internal/actions/registry.go @@ -6,10 +6,10 @@ type Registry struct { ordered []Action // maintains insertion order } -// NewRegistry creates a registry pre-populated with all 28 agent-callable actions. +// NewRegistry creates a registry pre-populated with all 33 agent-callable actions. func NewRegistry() *Registry { r := &Registry{ - actions: make(map[string]Action, 28), + actions: make(map[string]Action, 33), } for _, a := range allActions() { r.actions[a.Name] = a @@ -42,7 +42,7 @@ func (r *Registry) ListByCategory(category string) []Action { return out } -// allActions returns the canonical list of all 28 agent-callable actions. +// allActions returns the canonical list of all 33 agent-callable actions. func allActions() []Action { return []Action{ // ── Messaging (7 actions) ────────────────────────────────────── @@ -599,5 +599,78 @@ func allActions() []Action { }, }, }, + + // ── Wiki (5 actions) ────────────────────────────────────── + { + Name: "create_article", + Category: "wiki", + Description: "Create a new wiki article. Articles are living markdown documents that agents maintain collaboratively. Use [[slug]] syntax in the body to create backlinks to other articles.", + Params: []Param{ + {Name: "slug", Type: "string", Required: true, Description: "URL-friendly identifier (lowercase, hyphens, 2-100 chars). e.g. 'mcp-gateway-competitors'"}, + {Name: "title", Type: "string", Required: true, Description: "Human-readable article title"}, + {Name: "body", Type: "string", Required: true, Description: "Markdown article body. Use [[other-slug]] or [[other-slug|Display Text]] for wiki links"}, + }, + Returns: "Created article with id, slug, title, revision, created_at", + Examples: []Example{{ + Description: "Create an article about MCP security", + Code: `call("create_article", {"slug": "mcp-security-landscape", "title": "MCP Security Landscape", "body": "# MCP Security\n\nRelated: [[mcp-gateway-competitors]] and [[a2a-protocols]]"})`, + }}, + }, + { + Name: "get_article", + Category: "wiki", + Description: "Get a wiki article by its slug. Returns the current revision with metadata and backlinks.", + Params: []Param{ + {Name: "slug", Type: "string", Required: true, Description: "Article slug to retrieve"}, + {Name: "include_history", Type: "boolean", Description: "Include revision history (default false)"}, + }, + Returns: "Article with body, metadata, outgoing links, backlinks, and optional revision history", + Examples: []Example{{ + Description: "Read an article", + Code: `call("get_article", {"slug": "mcp-security-landscape"})`, + }}, + }, + { + Name: "update_article", + Category: "wiki", + Description: "Update a wiki article's body and/or title. Creates a new revision (previous content preserved in history). Re-extracts [[backlinks]] from the new body.", + Params: []Param{ + {Name: "slug", Type: "string", Required: true, Description: "Article slug to update"}, + {Name: "body", Type: "string", Required: true, Description: "New markdown body"}, + {Name: "title", Type: "string", Description: "New title (optional, keeps current if omitted)"}, + }, + Returns: "Updated article with new revision number", + Examples: []Example{{ + Description: "Add a section to an existing article", + Code: `call("update_article", {"slug": "mcp-security-landscape", "body": "# MCP Security\n\n## New Findings\n\n..."})`, + }}, + }, + { + Name: "list_articles", + Category: "wiki", + Description: "List or search wiki articles. Without a query, returns all articles sorted by last updated. With a query, searches titles and bodies using full-text search.", + Params: []Param{ + {Name: "query", Type: "string", Description: "Search query (optional). Searches article titles and bodies."}, + {Name: "limit", Type: "number", Description: "Max results (default 50, max 200)"}, + }, + Returns: "Array of article summaries with slug, title, revision, updated_at, word_count", + Examples: []Example{{ + Description: "Search for security-related articles", + Code: `call("list_articles", {"query": "security vulnerability", "limit": 10})`, + }}, + }, + { + Name: "get_backlinks", + Category: "wiki", + Description: "Get all articles that link to a given article via [[slug]] references. Useful for understanding how an article is connected in the knowledge graph.", + Params: []Param{ + {Name: "slug", Type: "string", Required: true, Description: "Article slug to find backlinks for"}, + }, + Returns: "Array of article summaries that contain [[slug]] links to this article", + Examples: []Example{{ + Description: "Find articles linking to mcp-security", + Code: `call("get_backlinks", {"slug": "mcp-security-landscape"})`, + }}, + }, } } diff --git a/internal/actions/registry_test.go b/internal/actions/registry_test.go index 1925e62..68a5b62 100644 --- a/internal/actions/registry_test.go +++ b/internal/actions/registry_test.go @@ -4,11 +4,11 @@ import ( "testing" ) -func TestRegistryHas30Actions(t *testing.T) { +func TestRegistryHas35Actions(t *testing.T) { r := NewRegistry() got := len(r.List()) - if got != 30 { - t.Errorf("expected 30 actions, got %d", got) + if got != 35 { + t.Errorf("expected 35 actions, got %d", got) } } @@ -26,6 +26,8 @@ func TestRegistryCategories(t *testing.T) { {"reactions", 4}, {"threads", 1}, {"trust", 1}, + {"data", 1}, + {"wiki", 5}, } for _, tt := range tests { @@ -60,6 +62,8 @@ func TestRegistryGetByName(t *testing.T) { "get_trust", // data "query", + // wiki + "create_article", "get_article", "update_article", "list_articles", "get_backlinks", } for _, name := range allNames { diff --git a/internal/api/router.go b/internal/api/router.go index a468aa6..8db312f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -18,6 +18,7 @@ import ( "github.com/synapbus/synapbus/internal/trace" "github.com/synapbus/synapbus/internal/trust" "github.com/synapbus/synapbus/internal/webhooks" + "github.com/synapbus/synapbus/internal/wiki" ) // RouterConfig holds optional services for the API router. @@ -40,6 +41,7 @@ type RouterConfig struct { TrustService *trust.Service ReactorStore *reactor.Store ReactorEngine *reactor.Reactor + WikiService *wiki.Service SSEHub *SSEHub Broadcaster *SSEBroadcaster SessionMiddleware func(http.Handler) http.Handler @@ -265,6 +267,18 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router { }) } + // Wiki + if cfg.WikiService != nil { + wikiHandler := NewWikiHandler(cfg.WikiService) + r.Group(func(r chi.Router) { + r.Use(authMiddleware) + r.Get("/api/wiki/articles", wikiHandler.ListArticles) + r.Get("/api/wiki/articles/{slug}", wikiHandler.GetArticle) + r.Get("/api/wiki/articles/{slug}/history", wikiHandler.GetHistory) + r.Get("/api/wiki/map", wikiHandler.GetMap) + }) + } + // Onboarding (CLAUDE.md generator, MCP config, archetypes, skills) if cfg.AgentService != nil { onboardingHandler := NewOnboardingHandler(cfg.AgentService, cfg.ChannelService, cfg.BaseURL) diff --git a/internal/api/wiki_handler.go b/internal/api/wiki_handler.go new file mode 100644 index 0000000..3cb6d32 --- /dev/null +++ b/internal/api/wiki_handler.go @@ -0,0 +1,100 @@ +package api + +import ( + "log/slog" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/synapbus/synapbus/internal/wiki" +) + +// WikiHandler handles REST API requests for wiki articles. +type WikiHandler struct { + wikiService *wiki.Service + logger *slog.Logger +} + +// NewWikiHandler creates a new wiki handler. +func NewWikiHandler(svc *wiki.Service) *WikiHandler { + return &WikiHandler{ + wikiService: svc, + logger: slog.Default().With("component", "api.wiki"), + } +} + +// ListArticles handles GET /api/wiki/articles?q=...&limit=50 +func (h *WikiHandler) ListArticles(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + limit := 50 + if l := r.URL.Query().Get("limit"); l != "" { + if v, err := strconv.Atoi(l); err == nil && v > 0 { + limit = v + } + } + + articles, err := h.wikiService.ListArticles(r.Context(), query, limit) + if err != nil { + h.logger.Error("list articles failed", "error", err) + writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", "Failed to list articles")) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "articles": articles, + "count": len(articles), + }) +} + +// GetArticle handles GET /api/wiki/articles/{slug} +func (h *WikiHandler) GetArticle(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + if slug == "" { + writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Slug is required")) + return + } + + article, err := h.wikiService.GetArticle(r.Context(), slug) + if err != nil { + h.logger.Error("get article failed", "slug", slug, "error", err) + writeJSON(w, http.StatusNotFound, errorBody("not_found", "Article not found")) + return + } + + writeJSON(w, http.StatusOK, article) +} + +// GetHistory handles GET /api/wiki/articles/{slug}/history +func (h *WikiHandler) GetHistory(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + if slug == "" { + writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Slug is required")) + return + } + + revisions, err := h.wikiService.GetRevisions(r.Context(), slug) + if err != nil { + h.logger.Error("get history failed", "slug", slug, "error", err) + writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", "Failed to get history")) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "slug": slug, + "revisions": revisions, + "count": len(revisions), + }) +} + +// GetMap handles GET /api/wiki/map +func (h *WikiHandler) GetMap(w http.ResponseWriter, r *http.Request) { + moc, err := h.wikiService.GetMapOfContent(r.Context()) + if err != nil { + h.logger.Error("get map failed", "error", err) + writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", "Failed to get map")) + return + } + + writeJSON(w, http.StatusOK, moc) +} diff --git a/internal/mcp/bridge.go b/internal/mcp/bridge.go index 93ed2a7..c633d5d 100644 --- a/internal/mcp/bridge.go +++ b/internal/mcp/bridge.go @@ -19,6 +19,7 @@ import ( "github.com/synapbus/synapbus/internal/reactions" "github.com/synapbus/synapbus/internal/search" "github.com/synapbus/synapbus/internal/trust" + "github.com/synapbus/synapbus/internal/wiki" ) // ServiceBridge implements jsruntime.ToolCaller, mapping action names to @@ -32,6 +33,7 @@ type ServiceBridge struct { searchService *search.Service reactionService *reactions.Service trustService *trust.Service + wikiService *wiki.Service queryExecutor *agentquery.Executor agentName string } @@ -46,6 +48,7 @@ func NewServiceBridge( searchService *search.Service, reactionService *reactions.Service, trustService *trust.Service, + wikiService *wiki.Service, agentName string, ) *ServiceBridge { return &ServiceBridge{ @@ -57,6 +60,7 @@ func NewServiceBridge( searchService: searchService, reactionService: reactionService, trustService: trustService, + wikiService: wikiService, agentName: agentName, } } @@ -136,6 +140,18 @@ func (b *ServiceBridge) Call(ctx context.Context, actionName string, args map[st case "query": return b.callQuery(ctx, args) + // --- Wiki --- + case "create_article": + return b.callCreateArticle(ctx, args) + case "get_article": + return b.callGetArticle(ctx, args) + case "update_article": + return b.callUpdateArticle(ctx, args) + case "list_articles": + return b.callListArticles(ctx, args) + case "get_backlinks": + return b.callGetBacklinks(ctx, args) + // --- DM send (also accessible via bridge for execute tool) --- case "send_message": return b.callSendMessage(ctx, args) @@ -1245,6 +1261,155 @@ func (b *ServiceBridge) callQuery(ctx context.Context, args map[string]any) (any return result, nil } +// --- Wiki implementations --- + +func (b *ServiceBridge) callCreateArticle(ctx context.Context, args map[string]any) (any, error) { + if b.wikiService == nil { + return nil, fmt.Errorf("wiki not available") + } + + slug := getString(args, "slug", "") + if slug == "" { + return nil, fmt.Errorf("'slug' parameter is required") + } + title := getString(args, "title", "") + if title == "" { + return nil, fmt.Errorf("'title' parameter is required") + } + body := getString(args, "body", "") + if body == "" { + return nil, fmt.Errorf("'body' parameter is required") + } + + article, err := b.wikiService.CreateArticle(ctx, slug, title, body, b.agentName) + if err != nil { + return nil, err + } + + return map[string]any{ + "id": article.ID, + "slug": article.Slug, + "title": article.Title, + "revision": article.Revision, + "word_count": article.WordCount, + "created_at": article.CreatedAt, + "outgoing_links": article.OutgoingLinks, + }, nil +} + +func (b *ServiceBridge) callGetArticle(ctx context.Context, args map[string]any) (any, error) { + if b.wikiService == nil { + return nil, fmt.Errorf("wiki not available") + } + + slug := getString(args, "slug", "") + if slug == "" { + return nil, fmt.Errorf("'slug' parameter is required") + } + + article, err := b.wikiService.GetArticle(ctx, slug) + if err != nil { + return nil, err + } + + result := map[string]any{ + "id": article.ID, + "slug": article.Slug, + "title": article.Title, + "body": article.Body, + "created_by": article.CreatedBy, + "updated_by": article.UpdatedBy, + "revision": article.Revision, + "word_count": article.WordCount, + "created_at": article.CreatedAt, + "updated_at": article.UpdatedAt, + "outgoing_links": article.OutgoingLinks, + "backlinks": article.Backlinks, + } + + if getBool(args, "include_history", false) { + revisions, err := b.wikiService.GetRevisions(ctx, slug) + if err != nil { + return nil, err + } + result["revisions"] = revisions + } + + return result, nil +} + +func (b *ServiceBridge) callUpdateArticle(ctx context.Context, args map[string]any) (any, error) { + if b.wikiService == nil { + return nil, fmt.Errorf("wiki not available") + } + + slug := getString(args, "slug", "") + if slug == "" { + return nil, fmt.Errorf("'slug' parameter is required") + } + body := getString(args, "body", "") + if body == "" { + return nil, fmt.Errorf("'body' parameter is required") + } + title := getString(args, "title", "") + + article, err := b.wikiService.UpdateArticle(ctx, slug, title, body, b.agentName) + if err != nil { + return nil, err + } + + return map[string]any{ + "id": article.ID, + "slug": article.Slug, + "title": article.Title, + "revision": article.Revision, + "word_count": article.WordCount, + "updated_at": article.UpdatedAt, + "outgoing_links": article.OutgoingLinks, + }, nil +} + +func (b *ServiceBridge) callListArticles(ctx context.Context, args map[string]any) (any, error) { + if b.wikiService == nil { + return nil, fmt.Errorf("wiki not available") + } + + query := getString(args, "query", "") + limit := getInt(args, "limit", 50) + + articles, err := b.wikiService.ListArticles(ctx, query, limit) + if err != nil { + return nil, err + } + + return map[string]any{ + "articles": articles, + "count": len(articles), + }, nil +} + +func (b *ServiceBridge) callGetBacklinks(ctx context.Context, args map[string]any) (any, error) { + if b.wikiService == nil { + return nil, fmt.Errorf("wiki not available") + } + + slug := getString(args, "slug", "") + if slug == "" { + return nil, fmt.Errorf("'slug' parameter is required") + } + + backlinks, err := b.wikiService.GetBacklinks(ctx, slug) + if err != nil { + return nil, err + } + + return map[string]any{ + "slug": slug, + "backlinks": backlinks, + "count": len(backlinks), + }, nil +} + // --- Helpers --- // resolveChannelID resolves a channel ID from either channel_id or channel_name in args. diff --git a/internal/mcp/bridge_test.go b/internal/mcp/bridge_test.go index ffb0d88..98342c8 100644 --- a/internal/mcp/bridge_test.go +++ b/internal/mcp/bridge_test.go @@ -47,6 +47,7 @@ func newTestBridge(t *testing.T) (*ServiceBridge, *messaging.MessagingService, * nil, // searchService nil, // reactionService nil, // trustService + nil, // wikiService "agent-a", ) return bridge, msgService, agentService, channelService @@ -189,7 +190,7 @@ func TestBridge_JoinChannel(t *testing.T) { bridge.agentService, bridge.channelService, bridge.swarmService, - nil, nil, nil, nil, + nil, nil, nil, nil, nil, "agent-b", ) @@ -331,6 +332,7 @@ func newTestBridgeWithReactions(t *testing.T) (*ServiceBridge, *channels.Service nil, // searchService reactionService, nil, // trustService + nil, // wikiService "agent-a", ) return bridge, channelService diff --git a/internal/mcp/channel_tools_test.go b/internal/mcp/channel_tools_test.go index 9fe1165..9f69f8c 100644 --- a/internal/mcp/channel_tools_test.go +++ b/internal/mcp/channel_tools_test.go @@ -52,6 +52,7 @@ func newTestHybridWithChannels(t *testing.T) (*HybridToolRegistrar, *channels.Se nil, // searchService nil, // reactionService nil, // trustService + nil, // wikiService jsPool, actionRegistry, actionIndex, diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 38cee2d..a59acdd 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -23,6 +23,7 @@ import ( "github.com/synapbus/synapbus/internal/search" "github.com/synapbus/synapbus/internal/trace" "github.com/synapbus/synapbus/internal/trust" + "github.com/synapbus/synapbus/internal/wiki" ) // MCPServer wraps the mcp-go server with SynapBus services. @@ -36,7 +37,7 @@ type MCPServer struct { console *console.Printer } -// NewMCPServer creates and configures a new MCP server with 4 hybrid tools registered. +// NewMCPServer creates and configures a new MCP server with 5 hybrid tools registered. func NewMCPServer( msgService *messaging.MessagingService, agentService *agents.AgentService, @@ -46,6 +47,7 @@ func NewMCPServer( searchService *search.Service, reactionService *reactions.Service, trustService *trust.Service, + wikiService *wiki.Service, consolePrinter *console.Printer, jsPool *jsruntime.Pool, actionRegistry *actions.Registry, @@ -161,6 +163,7 @@ func NewMCPServer( searchService, reactionService, trustService, + wikiService, jsPool, actionRegistry, actionIndex, diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 2597eab..ec1f5b9 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -38,7 +38,7 @@ func newTestMCPServer(t *testing.T, con *console.Printer) (*MCPServer, *messagin actionRegistry := actions.NewRegistry() actionIndex := actions.NewIndex(actionRegistry.List()) - srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db) + srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db) return srv, msgService, agentService } @@ -133,7 +133,7 @@ func TestMCPToolCall_WithValidAPIKey(t *testing.T) { actionIndex := actions.NewIndex(actionRegistry.List()) // Create MCP server - srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db) + srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db) // Mount with auth middleware, just like main.go does mux := http.NewServeMux() @@ -188,7 +188,7 @@ func TestMCPToolCall_InvalidAPIKeyReturns401(t *testing.T) { actionRegistry := actions.NewRegistry() actionIndex := actions.NewIndex(actionRegistry.List()) - srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db) + srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db) mux := http.NewServeMux() handler := agents.OptionalAuthMiddlewareWithAPIKeys(agentService, apiKeyService)(srv.Handler()) diff --git a/internal/mcp/tools_hybrid.go b/internal/mcp/tools_hybrid.go index a26f5c6..9b838ac 100644 --- a/internal/mcp/tools_hybrid.go +++ b/internal/mcp/tools_hybrid.go @@ -22,6 +22,7 @@ import ( "github.com/synapbus/synapbus/internal/reactions" "github.com/synapbus/synapbus/internal/search" "github.com/synapbus/synapbus/internal/trust" + "github.com/synapbus/synapbus/internal/wiki" ) // HybridToolRegistrar registers the 4 hybrid MCP tools. @@ -34,6 +35,7 @@ type HybridToolRegistrar struct { searchService *search.Service reactionService *reactions.Service trustService *trust.Service + wikiService *wiki.Service jsPool *jsruntime.Pool actionRegistry *actions.Registry actionIndex *actions.Index @@ -57,6 +59,7 @@ func NewHybridToolRegistrar( searchService *search.Service, reactionService *reactions.Service, trustService *trust.Service, + wikiService *wiki.Service, jsPool *jsruntime.Pool, actionRegistry *actions.Registry, actionIndex *actions.Index, @@ -71,6 +74,7 @@ func NewHybridToolRegistrar( searchService: searchService, reactionService: reactionService, trustService: trustService, + wikiService: wikiService, jsPool: jsPool, actionRegistry: actionRegistry, actionIndex: actionIndex, @@ -500,6 +504,7 @@ func (h *HybridToolRegistrar) handleExecute(ctx context.Context, req mcplib.Call h.searchService, h.reactionService, h.trustService, + h.wikiService, agentName, ) if h.queryExecutor != nil { diff --git a/internal/mcp/tools_test.go b/internal/mcp/tools_test.go index fc72e1f..2ece062 100644 --- a/internal/mcp/tools_test.go +++ b/internal/mcp/tools_test.go @@ -70,6 +70,7 @@ func newTestHybridRegistrar(t *testing.T) (*HybridToolRegistrar, *messaging.Mess nil, // searchService nil, // reactionService nil, // trustService + nil, // wikiService jsPool, actionRegistry, actionIndex, diff --git a/internal/storage/schema/017_wiki.sql b/internal/storage/schema/017_wiki.sql new file mode 100644 index 0000000..59efc83 --- /dev/null +++ b/internal/storage/schema/017_wiki.sql @@ -0,0 +1,61 @@ +-- 017: Wiki articles, revisions, link graph, and FTS + +-- Wiki articles +CREATE TABLE IF NOT EXISTS articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE CHECK(length(slug) >= 2 AND length(slug) <= 100), + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL, + updated_by TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_articles_slug ON articles(slug); +CREATE INDEX idx_articles_updated_at ON articles(updated_at); + +-- Revision history (full body per revision) +CREATE TABLE IF NOT EXISTS article_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE, + revision INTEGER NOT NULL, + body TEXT NOT NULL, + changed_by TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_article_revisions_article ON article_revisions(article_id, revision); + +-- Link graph (rebuilt on every article create/update) +CREATE TABLE IF NOT EXISTS article_links ( + from_slug TEXT NOT NULL, + to_slug TEXT NOT NULL, + display_text TEXT, + PRIMARY KEY (from_slug, to_slug) +); + +CREATE INDEX idx_article_links_to ON article_links(to_slug); + +-- FTS5 for article search +CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5( + title, + body, + content='articles', + content_rowid='id' +); + +-- FTS sync triggers +CREATE TRIGGER articles_fts_ai AFTER INSERT ON articles BEGIN + INSERT INTO articles_fts(rowid, title, body) VALUES (new.id, new.title, new.body); +END; +CREATE TRIGGER articles_fts_ad AFTER DELETE ON articles BEGIN + INSERT INTO articles_fts(articles_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body); +END; +CREATE TRIGGER articles_fts_au AFTER UPDATE ON articles BEGIN + INSERT INTO articles_fts(articles_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body); + INSERT INTO articles_fts(rowid, title, body) VALUES (new.id, new.title, new.body); +END; + +INSERT INTO schema_migrations (version) VALUES (17); diff --git a/internal/wiki/service.go b/internal/wiki/service.go new file mode 100644 index 0000000..8c7e50c --- /dev/null +++ b/internal/wiki/service.go @@ -0,0 +1,63 @@ +package wiki + +import ( + "context" + "database/sql" + "log/slog" +) + +// Service provides business logic for wiki articles. +type Service struct { + store *Store + logger *slog.Logger +} + +// NewService creates a new wiki service. +func NewService(db *sql.DB) *Service { + return &Service{ + store: NewStore(db), + logger: slog.Default().With("component", "wiki"), + } +} + +// CreateArticle creates a new wiki article. +func (s *Service) CreateArticle(ctx context.Context, slug, title, body, author string) (*Article, error) { + s.logger.Info("creating article", "slug", slug, "author", author) + return s.store.CreateArticle(ctx, slug, title, body, author) +} + +// GetArticle retrieves an article by slug. +func (s *Service) GetArticle(ctx context.Context, slug string) (*Article, error) { + return s.store.GetArticle(ctx, slug) +} + +// UpdateArticle updates an article's body and/or title. +func (s *Service) UpdateArticle(ctx context.Context, slug, title, body, author string) (*Article, error) { + s.logger.Info("updating article", "slug", slug, "author", author) + return s.store.UpdateArticle(ctx, slug, title, body, author) +} + +// ListArticles lists or searches wiki articles. +func (s *Service) ListArticles(ctx context.Context, query string, limit int) ([]ArticleSummary, error) { + return s.store.ListArticles(ctx, query, limit) +} + +// GetRevisions returns the revision history for an article. +func (s *Service) GetRevisions(ctx context.Context, slug string) ([]ArticleRevision, error) { + return s.store.GetRevisions(ctx, slug) +} + +// GetBacklinks returns articles linking to the given slug. +func (s *Service) GetBacklinks(ctx context.Context, slug string) ([]ArticleSummary, error) { + return s.store.GetBacklinks(ctx, slug) +} + +// GetOutgoingLinks returns links from the given article. +func (s *Service) GetOutgoingLinks(ctx context.Context, slug string) ([]ArticleLink, error) { + return s.store.GetOutgoingLinks(ctx, slug) +} + +// GetMapOfContent returns the wiki knowledge graph overview. +func (s *Service) GetMapOfContent(ctx context.Context) (*MapOfContent, error) { + return s.store.GetMapOfContent(ctx) +} diff --git a/internal/wiki/store.go b/internal/wiki/store.go new file mode 100644 index 0000000..1c01c7c --- /dev/null +++ b/internal/wiki/store.go @@ -0,0 +1,535 @@ +package wiki + +import ( + "context" + "database/sql" + "fmt" + "regexp" + "strings" +) + +// slugRe validates article slugs: lowercase letters, digits, hyphens, 2-100 chars. +var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`) + +// wikiLinkRe matches [[slug]] or [[slug|Display Text]] in article bodies. +var wikiLinkRe = regexp.MustCompile(`\[\[([a-z0-9][a-z0-9-]*[a-z0-9])(?:\|([^\]]+))?\]\]`) + +// ValidateSlug checks that a slug meets the wiki naming rules. +func ValidateSlug(slug string) error { + if len(slug) < 2 || len(slug) > 100 { + return fmt.Errorf("slug must be 2-100 characters") + } + if !slugRe.MatchString(slug) { + return fmt.Errorf("slug must be lowercase letters, numbers, and hyphens (e.g. 'mcp-security')") + } + return nil +} + +// ExtractLinks parses [[slug]] and [[slug|display]] references from markdown body. +func ExtractLinks(body string) []ArticleLink { + matches := wikiLinkRe.FindAllStringSubmatch(body, -1) + seen := make(map[string]bool) + var links []ArticleLink + for _, m := range matches { + slug := m[1] + if seen[slug] { + continue + } + seen[slug] = true + link := ArticleLink{ToSlug: slug} + if len(m) > 2 && m[2] != "" { + link.DisplayText = m[2] + } + links = append(links, link) + } + return links +} + +// WordCount returns the number of whitespace-delimited words in s. +func WordCount(s string) int { + return len(strings.Fields(s)) +} + +// Store provides SQLite-backed CRUD operations for wiki articles. +type Store struct { + db *sql.DB +} + +// NewStore creates a new wiki store. +func NewStore(db *sql.DB) *Store { + return &Store{db: db} +} + +// CreateArticle inserts a new article, its first revision, and link graph entries. +func (s *Store) CreateArticle(ctx context.Context, slug, title, body, author string) (*Article, error) { + if err := ValidateSlug(slug); err != nil { + return nil, err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + result, err := tx.ExecContext(ctx, + `INSERT INTO articles (slug, title, body, created_by, updated_by, revision, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, + slug, title, body, author, author, + ) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + return nil, fmt.Errorf("article with slug %q already exists", slug) + } + return nil, fmt.Errorf("insert article: %w", err) + } + + articleID, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("get article id: %w", err) + } + + // Insert first revision + _, err = tx.ExecContext(ctx, + `INSERT INTO article_revisions (article_id, revision, body, changed_by, created_at) + VALUES (?, 1, ?, ?, CURRENT_TIMESTAMP)`, + articleID, body, author, + ) + if err != nil { + return nil, fmt.Errorf("insert revision: %w", err) + } + + // Extract and store links + if err := s.updateLinks(ctx, tx, slug, body); err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + + return s.GetArticle(ctx, slug) +} + +// GetArticle retrieves the current version of an article by slug. +func (s *Store) GetArticle(ctx context.Context, slug string) (*Article, error) { + var a Article + err := s.db.QueryRowContext(ctx, + `SELECT id, slug, title, body, created_by, updated_by, revision, created_at, updated_at + FROM articles WHERE slug = ?`, slug, + ).Scan(&a.ID, &a.Slug, &a.Title, &a.Body, &a.CreatedBy, &a.UpdatedBy, &a.Revision, &a.CreatedAt, &a.UpdatedAt) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("article %q not found", slug) + } + if err != nil { + return nil, fmt.Errorf("get article: %w", err) + } + + a.WordCount = WordCount(a.Body) + + // Get outgoing links + outgoing, err := s.GetOutgoingLinks(ctx, slug) + if err != nil { + return nil, err + } + a.OutgoingLinks = outgoing + + // Get backlinks + backlinks, err := s.GetBacklinks(ctx, slug) + if err != nil { + return nil, err + } + a.Backlinks = backlinks + + return &a, nil +} + +// UpdateArticle updates an article's body and/or title, creating a new revision. +func (s *Store) UpdateArticle(ctx context.Context, slug, title, body, author string) (*Article, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + // Get current article + var articleID int64 + var currentRevision int + var currentTitle string + err = tx.QueryRowContext(ctx, + `SELECT id, revision, title FROM articles WHERE slug = ?`, slug, + ).Scan(&articleID, ¤tRevision, ¤tTitle) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("article %q not found", slug) + } + if err != nil { + return nil, fmt.Errorf("get article for update: %w", err) + } + + newRevision := currentRevision + 1 + if title == "" { + title = currentTitle + } + + // Update article + _, err = tx.ExecContext(ctx, + `UPDATE articles SET title = ?, body = ?, updated_by = ?, revision = ?, updated_at = CURRENT_TIMESTAMP + WHERE slug = ?`, + title, body, author, newRevision, slug, + ) + if err != nil { + return nil, fmt.Errorf("update article: %w", err) + } + + // Insert new revision + _, err = tx.ExecContext(ctx, + `INSERT INTO article_revisions (article_id, revision, body, changed_by, created_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`, + articleID, newRevision, body, author, + ) + if err != nil { + return nil, fmt.Errorf("insert revision: %w", err) + } + + // Re-extract and store links + if err := s.updateLinks(ctx, tx, slug, body); err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + + return s.GetArticle(ctx, slug) +} + +// ListArticles returns article summaries, optionally filtered by FTS query. +func (s *Store) ListArticles(ctx context.Context, query string, limit int) ([]ArticleSummary, error) { + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + + var rows *sql.Rows + var err error + + if query != "" { + sanitized := sanitizeFTS5Query(query) + rows, err = s.db.QueryContext(ctx, + `SELECT a.slug, a.title, a.updated_at, a.revision, a.body + FROM articles a + JOIN articles_fts ON articles_fts.rowid = a.id + WHERE articles_fts MATCH ? + ORDER BY rank + LIMIT ?`, sanitized, limit, + ) + } else { + rows, err = s.db.QueryContext(ctx, + `SELECT slug, title, updated_at, revision, body + FROM articles + ORDER BY updated_at DESC + LIMIT ?`, limit, + ) + } + if err != nil { + return nil, fmt.Errorf("list articles: %w", err) + } + defer rows.Close() + + var articles []ArticleSummary + for rows.Next() { + var as ArticleSummary + var body string + if err := rows.Scan(&as.Slug, &as.Title, &as.UpdatedAt, &as.Revision, &body); err != nil { + return nil, fmt.Errorf("scan article: %w", err) + } + as.WordCount = WordCount(body) + articles = append(articles, as) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate articles: %w", err) + } + + if articles == nil { + articles = []ArticleSummary{} + } + return articles, nil +} + +// GetRevisions returns the revision history for an article. +func (s *Store) GetRevisions(ctx context.Context, slug string) ([]ArticleRevision, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT r.id, r.revision, r.body, r.changed_by, r.created_at + FROM article_revisions r + JOIN articles a ON a.id = r.article_id + WHERE a.slug = ? + ORDER BY r.revision DESC`, slug, + ) + if err != nil { + return nil, fmt.Errorf("get revisions: %w", err) + } + defer rows.Close() + + var revisions []ArticleRevision + for rows.Next() { + var rev ArticleRevision + if err := rows.Scan(&rev.ID, &rev.Revision, &rev.Body, &rev.ChangedBy, &rev.CreatedAt); err != nil { + return nil, fmt.Errorf("scan revision: %w", err) + } + rev.WordCount = WordCount(rev.Body) + revisions = append(revisions, rev) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate revisions: %w", err) + } + + if revisions == nil { + revisions = []ArticleRevision{} + } + return revisions, nil +} + +// GetBacklinks returns articles that link TO the given slug. +func (s *Store) GetBacklinks(ctx context.Context, slug string) ([]ArticleSummary, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT a.slug, a.title, a.updated_at, a.revision, a.body + FROM articles a + JOIN article_links l ON l.from_slug = a.slug + WHERE l.to_slug = ? + ORDER BY a.updated_at DESC`, slug, + ) + if err != nil { + return nil, fmt.Errorf("get backlinks: %w", err) + } + defer rows.Close() + + var backlinks []ArticleSummary + for rows.Next() { + var as ArticleSummary + var body string + if err := rows.Scan(&as.Slug, &as.Title, &as.UpdatedAt, &as.Revision, &body); err != nil { + return nil, fmt.Errorf("scan backlink: %w", err) + } + as.WordCount = WordCount(body) + backlinks = append(backlinks, as) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate backlinks: %w", err) + } + + if backlinks == nil { + backlinks = []ArticleSummary{} + } + return backlinks, nil +} + +// GetOutgoingLinks returns links FROM the given article. +func (s *Store) GetOutgoingLinks(ctx context.Context, slug string) ([]ArticleLink, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT from_slug, to_slug, COALESCE(display_text, '') FROM article_links WHERE from_slug = ?`, slug, + ) + if err != nil { + return nil, fmt.Errorf("get outgoing links: %w", err) + } + defer rows.Close() + + var links []ArticleLink + for rows.Next() { + var l ArticleLink + if err := rows.Scan(&l.FromSlug, &l.ToSlug, &l.DisplayText); err != nil { + return nil, fmt.Errorf("scan link: %w", err) + } + links = append(links, l) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate links: %w", err) + } + + if links == nil { + links = []ArticleLink{} + } + return links, nil +} + +// GetMapOfContent returns a bird's-eye view of the wiki knowledge graph. +func (s *Store) GetMapOfContent(ctx context.Context) (*MapOfContent, error) { + moc := &MapOfContent{} + + // Get all articles + rows, err := s.db.QueryContext(ctx, + `SELECT slug, title, updated_at, revision, body FROM articles ORDER BY updated_at DESC`, + ) + if err != nil { + return nil, fmt.Errorf("get all articles: %w", err) + } + defer rows.Close() + + type articleInfo struct { + summary ArticleSummary + } + + var allArticles []articleInfo + slugSet := make(map[string]bool) + for rows.Next() { + var as ArticleSummary + var body string + if err := rows.Scan(&as.Slug, &as.Title, &as.UpdatedAt, &as.Revision, &body); err != nil { + return nil, fmt.Errorf("scan article: %w", err) + } + as.WordCount = WordCount(body) + allArticles = append(allArticles, articleInfo{summary: as}) + slugSet[as.Slug] = true + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate articles: %w", err) + } + + moc.Total = len(allArticles) + + // Get backlink counts per article + backlinkCounts := make(map[string]int) + blRows, err := s.db.QueryContext(ctx, + `SELECT to_slug, COUNT(*) FROM article_links GROUP BY to_slug`, + ) + if err != nil { + return nil, fmt.Errorf("get backlink counts: %w", err) + } + defer blRows.Close() + for blRows.Next() { + var slug string + var count int + if err := blRows.Scan(&slug, &count); err != nil { + return nil, fmt.Errorf("scan backlink count: %w", err) + } + backlinkCounts[slug] = count + } + if err := blRows.Err(); err != nil { + return nil, fmt.Errorf("iterate backlink counts: %w", err) + } + + // Get outgoing link counts per article + outgoingCounts := make(map[string]int) + olRows, err := s.db.QueryContext(ctx, + `SELECT from_slug, COUNT(*) FROM article_links GROUP BY from_slug`, + ) + if err != nil { + return nil, fmt.Errorf("get outgoing counts: %w", err) + } + defer olRows.Close() + for olRows.Next() { + var slug string + var count int + if err := olRows.Scan(&slug, &count); err != nil { + return nil, fmt.Errorf("scan outgoing count: %w", err) + } + outgoingCounts[slug] = count + } + if err := olRows.Err(); err != nil { + return nil, fmt.Errorf("iterate outgoing counts: %w", err) + } + + // Classify articles + for _, ai := range allArticles { + blCount := backlinkCounts[ai.summary.Slug] + + // Hubs: articles with 2+ backlinks + if blCount >= 2 { + moc.Hubs = append(moc.Hubs, ArticleWithLinks{ + ArticleSummary: ai.summary, + BacklinkCount: blCount, + }) + } + + // Orphans: articles with no backlinks AND no outgoing links + if blCount == 0 && outgoingCounts[ai.summary.Slug] == 0 { + moc.Orphans = append(moc.Orphans, ai.summary) + } + + moc.Articles = append(moc.Articles, ai.summary) + } + + // Wanted: slugs referenced in links but not existing as articles + wantedRows, err := s.db.QueryContext(ctx, + `SELECT to_slug, COUNT(*) as cnt + FROM article_links + WHERE to_slug NOT IN (SELECT slug FROM articles) + GROUP BY to_slug + ORDER BY cnt DESC`, + ) + if err != nil { + return nil, fmt.Errorf("get wanted articles: %w", err) + } + defer wantedRows.Close() + for wantedRows.Next() { + var w WantedArticle + if err := wantedRows.Scan(&w.Slug, &w.ReferencedCount); err != nil { + return nil, fmt.Errorf("scan wanted: %w", err) + } + moc.Wanted = append(moc.Wanted, w) + } + if err := wantedRows.Err(); err != nil { + return nil, fmt.Errorf("iterate wanted: %w", err) + } + + // Ensure non-nil slices for JSON + if moc.Hubs == nil { + moc.Hubs = []ArticleWithLinks{} + } + if moc.Articles == nil { + moc.Articles = []ArticleSummary{} + } + if moc.Orphans == nil { + moc.Orphans = []ArticleSummary{} + } + if moc.Wanted == nil { + moc.Wanted = []WantedArticle{} + } + + return moc, nil +} + +// updateLinks deletes old links from the article and inserts new ones extracted from body. +func (s *Store) updateLinks(ctx context.Context, tx *sql.Tx, fromSlug, body string) error { + _, err := tx.ExecContext(ctx, `DELETE FROM article_links WHERE from_slug = ?`, fromSlug) + if err != nil { + return fmt.Errorf("delete old links: %w", err) + } + + links := ExtractLinks(body) + for _, l := range links { + _, err := tx.ExecContext(ctx, + `INSERT OR REPLACE INTO article_links (from_slug, to_slug, display_text) VALUES (?, ?, ?)`, + fromSlug, l.ToSlug, l.DisplayText, + ) + if err != nil { + return fmt.Errorf("insert link %s -> %s: %w", fromSlug, l.ToSlug, err) + } + } + return nil +} + +// sanitizeFTS5Query escapes FTS5 reserved words and operators by wrapping each +// token in double quotes, turning them into literal phrase tokens. +func sanitizeFTS5Query(query string) string { + query = strings.TrimSpace(query) + if query == "" { + return "" + } + + tokens := strings.Fields(query) + quoted := make([]string, 0, len(tokens)) + for _, tok := range tokens { + // Already double-quoted — leave as-is + if len(tok) >= 2 && tok[0] == '"' && tok[len(tok)-1] == '"' { + quoted = append(quoted, tok) + continue + } + // Wrap in double quotes to make it a literal FTS5 phrase. + // Escape any embedded double quotes by doubling them. + escaped := strings.ReplaceAll(tok, `"`, `""`) + quoted = append(quoted, `"`+escaped+`"`) + } + return strings.Join(quoted, " ") +} diff --git a/internal/wiki/store_test.go b/internal/wiki/store_test.go new file mode 100644 index 0000000..0b6527a --- /dev/null +++ b/internal/wiki/store_test.go @@ -0,0 +1,531 @@ +package wiki + +import ( + "context" + "database/sql" + "fmt" + "testing" + + _ "modernc.org/sqlite" + + "github.com/synapbus/synapbus/internal/storage" +) + +func newTestDB(t *testing.T) *sql.DB { + t.Helper() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { db.Close() }) + + if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { + t.Fatalf("enable foreign keys: %v", err) + } + + ctx := context.Background() + if err := storage.RunMigrations(ctx, db); err != nil { + t.Fatalf("run migrations: %v", err) + } + + return db +} + +func TestCreateArticle(t *testing.T) { + db := newTestDB(t) + store := NewStore(db) + ctx := context.Background() + + t.Run("create success", func(t *testing.T) { + a, err := store.CreateArticle(ctx, "mcp-security", "MCP Security", "# Security\n\nMCP security overview", "agent-a") + if err != nil { + t.Fatalf("create article: %v", err) + } + if a.Slug != "mcp-security" { + t.Errorf("slug = %q, want %q", a.Slug, "mcp-security") + } + if a.Title != "MCP Security" { + t.Errorf("title = %q, want %q", a.Title, "MCP Security") + } + if a.Revision != 1 { + t.Errorf("revision = %d, want 1", a.Revision) + } + if a.CreatedBy != "agent-a" { + t.Errorf("created_by = %q, want %q", a.CreatedBy, "agent-a") + } + if a.WordCount != 5 { + t.Errorf("word_count = %d, want 5", a.WordCount) + } + }) + + t.Run("duplicate slug error", func(t *testing.T) { + _, err := store.CreateArticle(ctx, "mcp-security", "Another", "body", "agent-b") + if err == nil { + t.Fatal("expected error for duplicate slug") + } + }) + + t.Run("invalid slug error", func(t *testing.T) { + tests := []struct { + slug string + }{ + {""}, + {"a"}, + {"A-B"}, + {"-abc"}, + {"abc-"}, + {"a b c"}, + } + for _, tt := range tests { + _, err := store.CreateArticle(ctx, tt.slug, "Title", "Body", "agent-a") + if err == nil { + t.Errorf("expected error for slug %q", tt.slug) + } + } + }) + + t.Run("create with links", func(t *testing.T) { + a, err := store.CreateArticle(ctx, "overview-page", "Overview", "See [[mcp-security]] and [[a2a-protocols|A2A]]", "agent-a") + if err != nil { + t.Fatalf("create article: %v", err) + } + if len(a.OutgoingLinks) != 2 { + t.Fatalf("outgoing links = %d, want 2", len(a.OutgoingLinks)) + } + linkMap := make(map[string]string) + for _, l := range a.OutgoingLinks { + linkMap[l.ToSlug] = l.DisplayText + } + if _, ok := linkMap["mcp-security"]; !ok { + t.Error("expected link to mcp-security") + } + if dt, ok := linkMap["a2a-protocols"]; !ok { + t.Error("expected link to a2a-protocols") + } else if dt != "A2A" { + t.Errorf("a2a-protocols display_text = %q, want %q", dt, "A2A") + } + }) +} + +func TestGetArticle(t *testing.T) { + db := newTestDB(t) + store := NewStore(db) + ctx := context.Background() + + t.Run("exists", func(t *testing.T) { + store.CreateArticle(ctx, "test-get", "Test Get", "Body here", "agent-a") + a, err := store.GetArticle(ctx, "test-get") + if err != nil { + t.Fatalf("get article: %v", err) + } + if a.Slug != "test-get" { + t.Errorf("slug = %q, want %q", a.Slug, "test-get") + } + if a.Body != "Body here" { + t.Errorf("body = %q, want %q", a.Body, "Body here") + } + }) + + t.Run("not found", func(t *testing.T) { + _, err := store.GetArticle(ctx, "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent slug") + } + }) +} + +func TestUpdateArticle(t *testing.T) { + db := newTestDB(t) + store := NewStore(db) + ctx := context.Background() + + t.Run("update body and revision increments", func(t *testing.T) { + store.CreateArticle(ctx, "update-me", "Update Me", "Original body", "agent-a") + + a, err := store.UpdateArticle(ctx, "update-me", "", "New body with [[linked-article]]", "agent-b") + if err != nil { + t.Fatalf("update article: %v", err) + } + if a.Revision != 2 { + t.Errorf("revision = %d, want 2", a.Revision) + } + if a.Body != "New body with [[linked-article]]" { + t.Errorf("body = %q, want updated body", a.Body) + } + if a.UpdatedBy != "agent-b" { + t.Errorf("updated_by = %q, want %q", a.UpdatedBy, "agent-b") + } + if a.Title != "Update Me" { + t.Errorf("title = %q, want %q (should keep original)", a.Title, "Update Me") + } + if len(a.OutgoingLinks) != 1 { + t.Errorf("outgoing links = %d, want 1", len(a.OutgoingLinks)) + } + }) + + t.Run("update title", func(t *testing.T) { + a, err := store.UpdateArticle(ctx, "update-me", "New Title", "New body v3", "agent-a") + if err != nil { + t.Fatalf("update article: %v", err) + } + if a.Title != "New Title" { + t.Errorf("title = %q, want %q", a.Title, "New Title") + } + if a.Revision != 3 { + t.Errorf("revision = %d, want 3", a.Revision) + } + }) + + t.Run("links re-extracted on update", func(t *testing.T) { + store.CreateArticle(ctx, "link-test", "Link Test", "See [[update-me]]", "agent-a") + + // Verify backlinks to update-me + backlinks, err := store.GetBacklinks(ctx, "update-me") + if err != nil { + t.Fatalf("get backlinks: %v", err) + } + found := false + for _, bl := range backlinks { + if bl.Slug == "link-test" { + found = true + } + } + if !found { + t.Error("expected backlink from link-test to update-me") + } + + // Update link-test to remove the link + store.UpdateArticle(ctx, "link-test", "", "No more links here", "agent-a") + backlinks, err = store.GetBacklinks(ctx, "update-me") + if err != nil { + t.Fatalf("get backlinks after update: %v", err) + } + for _, bl := range backlinks { + if bl.Slug == "link-test" { + t.Error("backlink from link-test should have been removed") + } + } + }) + + t.Run("update nonexistent", func(t *testing.T) { + _, err := store.UpdateArticle(ctx, "no-such-article", "", "body", "agent-a") + if err == nil { + t.Fatal("expected error for nonexistent slug") + } + }) +} + +func TestListArticles(t *testing.T) { + db := newTestDB(t) + store := NewStore(db) + ctx := context.Background() + + store.CreateArticle(ctx, "alpha-topic", "Alpha Topic", "This is about alpha testing", "agent-a") + store.CreateArticle(ctx, "beta-topic", "Beta Topic", "This is about beta testing", "agent-a") + store.CreateArticle(ctx, "gamma-topic", "Gamma Topic", "Gamma is totally different", "agent-a") + + t.Run("list all", func(t *testing.T) { + articles, err := store.ListArticles(ctx, "", 50) + if err != nil { + t.Fatalf("list articles: %v", err) + } + if len(articles) != 3 { + t.Errorf("count = %d, want 3", len(articles)) + } + }) + + t.Run("FTS search", func(t *testing.T) { + articles, err := store.ListArticles(ctx, "alpha", 50) + if err != nil { + t.Fatalf("search articles: %v", err) + } + if len(articles) != 1 { + t.Errorf("count = %d, want 1", len(articles)) + } + if len(articles) > 0 && articles[0].Slug != "alpha-topic" { + t.Errorf("slug = %q, want %q", articles[0].Slug, "alpha-topic") + } + }) + + t.Run("FTS search title", func(t *testing.T) { + articles, err := store.ListArticles(ctx, "Gamma", 50) + if err != nil { + t.Fatalf("search articles: %v", err) + } + if len(articles) != 1 { + t.Errorf("count = %d, want 1", len(articles)) + } + }) + + t.Run("limit", func(t *testing.T) { + articles, err := store.ListArticles(ctx, "", 2) + if err != nil { + t.Fatalf("list articles: %v", err) + } + if len(articles) != 2 { + t.Errorf("count = %d, want 2", len(articles)) + } + }) + + t.Run("no results", func(t *testing.T) { + articles, err := store.ListArticles(ctx, "zzzznonexistent", 50) + if err != nil { + t.Fatalf("search articles: %v", err) + } + if len(articles) != 0 { + t.Errorf("count = %d, want 0", len(articles)) + } + }) +} + +func TestGetRevisions(t *testing.T) { + db := newTestDB(t) + store := NewStore(db) + ctx := context.Background() + + store.CreateArticle(ctx, "rev-test", "Rev Test", "Version 1 body", "agent-a") + store.UpdateArticle(ctx, "rev-test", "", "Version 2 body", "agent-b") + store.UpdateArticle(ctx, "rev-test", "", "Version 3 body", "agent-a") + + revisions, err := store.GetRevisions(ctx, "rev-test") + if err != nil { + t.Fatalf("get revisions: %v", err) + } + if len(revisions) != 3 { + t.Fatalf("revision count = %d, want 3", len(revisions)) + } + // Revisions are ordered DESC + if revisions[0].Revision != 3 { + t.Errorf("latest revision = %d, want 3", revisions[0].Revision) + } + if revisions[0].ChangedBy != "agent-a" { + t.Errorf("latest changed_by = %q, want %q", revisions[0].ChangedBy, "agent-a") + } + if revisions[2].Revision != 1 { + t.Errorf("oldest revision = %d, want 1", revisions[2].Revision) + } + if revisions[2].Body != "Version 1 body" { + t.Errorf("oldest body = %q, want %q", revisions[2].Body, "Version 1 body") + } +} + +func TestGetBacklinks(t *testing.T) { + db := newTestDB(t) + store := NewStore(db) + ctx := context.Background() + + store.CreateArticle(ctx, "target-article", "Target", "I am the target", "agent-a") + store.CreateArticle(ctx, "linker-one", "Linker One", "See [[target-article]] for details", "agent-a") + store.CreateArticle(ctx, "linker-two", "Linker Two", "Also references [[target-article]]", "agent-b") + store.CreateArticle(ctx, "unrelated", "Unrelated", "No links here", "agent-a") + + backlinks, err := store.GetBacklinks(ctx, "target-article") + if err != nil { + t.Fatalf("get backlinks: %v", err) + } + if len(backlinks) != 2 { + t.Fatalf("backlink count = %d, want 2", len(backlinks)) + } + + slugs := map[string]bool{} + for _, bl := range backlinks { + slugs[bl.Slug] = true + } + if !slugs["linker-one"] { + t.Error("expected backlink from linker-one") + } + if !slugs["linker-two"] { + t.Error("expected backlink from linker-two") + } +} + +func TestExtractLinks(t *testing.T) { + tests := []struct { + name string + body string + want int + slugs []string + }{ + { + name: "simple link", + body: "See [[mcp-security]] for details", + want: 1, + slugs: []string{"mcp-security"}, + }, + { + name: "link with display text", + body: "Check [[a2a-protocols|A2A Protocols]]", + want: 1, + slugs: []string{"a2a-protocols"}, + }, + { + name: "multiple links", + body: "See [[alpha-topic]] and [[beta-topic]] and [[gamma-topic]]", + want: 3, + slugs: []string{"alpha-topic", "beta-topic", "gamma-topic"}, + }, + { + name: "duplicate links deduped", + body: "[[mcp-security]] is great. Also see [[mcp-security]]", + want: 1, + slugs: []string{"mcp-security"}, + }, + { + name: "no matches", + body: "No wiki links here at all", + want: 0, + slugs: nil, + }, + { + name: "invalid slug format not matched", + body: "[[A]] and [[-abc]] and [[abc-]] are invalid", + want: 0, + slugs: nil, + }, + { + name: "mixed valid and invalid", + body: "Valid: [[good-slug]] Invalid: [[A]] [[ab]]", + want: 2, + slugs: []string{"good-slug", "ab"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + links := ExtractLinks(tt.body) + if len(links) != tt.want { + t.Errorf("link count = %d, want %d", len(links), tt.want) + } + for i, slug := range tt.slugs { + if i < len(links) && links[i].ToSlug != slug { + t.Errorf("link[%d].to_slug = %q, want %q", i, links[i].ToSlug, slug) + } + } + }) + } +} + +func TestValidateSlug(t *testing.T) { + tests := []struct { + slug string + wantErr bool + }{ + {"ab", false}, + {"mcp-security", false}, + {"a2a-protocols", false}, + {"topic123", false}, + {"a", true}, // too short + {"", true}, // empty + {"A-B", true}, // uppercase + {"-abc", true}, // starts with hyphen + {"abc-", true}, // ends with hyphen + {"a b c", true}, // spaces + {"a_b", true}, // underscores + {"ab!c", true}, // special chars + } + + for _, tt := range tests { + t.Run(tt.slug, func(t *testing.T) { + err := ValidateSlug(tt.slug) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateSlug(%q) error = %v, wantErr %v", tt.slug, err, tt.wantErr) + } + }) + } +} + +func TestGetMapOfContent(t *testing.T) { + db := newTestDB(t) + store := NewStore(db) + ctx := context.Background() + + // Create a hub (article with 2+ backlinks) + store.CreateArticle(ctx, "hub-article", "Hub", "I am a hub", "agent-a") + store.CreateArticle(ctx, "spoke-one", "Spoke 1", "Links to [[hub-article]]", "agent-a") + store.CreateArticle(ctx, "spoke-two", "Spoke 2", "Also links to [[hub-article]]", "agent-b") + + // Create an orphan (no links in or out) + store.CreateArticle(ctx, "orphan-article", "Orphan", "I am alone", "agent-a") + + // Create a wanted article reference (referenced but not created) + store.CreateArticle(ctx, "has-wanted", "Has Wanted", "See [[nonexistent-topic]]", "agent-a") + + moc, err := store.GetMapOfContent(ctx) + if err != nil { + t.Fatalf("get map: %v", err) + } + + if moc.Total != 5 { + t.Errorf("total = %d, want 5", moc.Total) + } + + // Check hubs + if len(moc.Hubs) != 1 { + t.Errorf("hubs count = %d, want 1", len(moc.Hubs)) + } else if moc.Hubs[0].Slug != "hub-article" { + t.Errorf("hub slug = %q, want %q", moc.Hubs[0].Slug, "hub-article") + } else if moc.Hubs[0].BacklinkCount != 2 { + t.Errorf("hub backlink count = %d, want 2", moc.Hubs[0].BacklinkCount) + } + + // Check orphans + if len(moc.Orphans) != 1 { + t.Errorf("orphans count = %d, want 1", len(moc.Orphans)) + } else if moc.Orphans[0].Slug != "orphan-article" { + t.Errorf("orphan slug = %q, want %q", moc.Orphans[0].Slug, "orphan-article") + } + + // Check wanted + if len(moc.Wanted) != 1 { + t.Errorf("wanted count = %d, want 1", len(moc.Wanted)) + } else if moc.Wanted[0].Slug != "nonexistent-topic" { + t.Errorf("wanted slug = %q, want %q", moc.Wanted[0].Slug, "nonexistent-topic") + } else if moc.Wanted[0].ReferencedCount != 1 { + t.Errorf("wanted ref count = %d, want 1", moc.Wanted[0].ReferencedCount) + } + + // All articles listed + if len(moc.Articles) != 5 { + t.Errorf("articles count = %d, want 5", len(moc.Articles)) + } +} + +func TestWordCount(t *testing.T) { + tests := []struct { + input string + want int + }{ + {"", 0}, + {"hello", 1}, + {"hello world", 2}, + {" spaces everywhere ", 2}, + {"one\ttwo\nthree", 3}, + } + for _, tt := range tests { + got := WordCount(tt.input) + if got != tt.want { + t.Errorf("WordCount(%q) = %d, want %d", tt.input, got, tt.want) + } + } +} + +func TestSanitizeFTS5Query(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", ""}, + {"hello", `"hello"`}, + {"hello world", `"hello" "world"`}, + {"to from and", `"to" "from" "and"`}, + {`"quoted"`, `"quoted"`}, + {`"quoted" unquoted`, `"quoted" "unquoted"`}, + } + for _, tt := range tests { + got := sanitizeFTS5Query(tt.input) + if got != tt.want { + t.Errorf("sanitizeFTS5Query(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/internal/wiki/types.go b/internal/wiki/types.go new file mode 100644 index 0000000..7ebe1c0 --- /dev/null +++ b/internal/wiki/types.go @@ -0,0 +1,67 @@ +package wiki + +import "time" + +// Article represents a wiki article with its current state. +type Article struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + CreatedBy string `json:"created_by"` + UpdatedBy string `json:"updated_by"` + Revision int `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + // Computed fields + WordCount int `json:"word_count,omitempty"` + OutgoingLinks []ArticleLink `json:"outgoing_links,omitempty"` + Backlinks []ArticleSummary `json:"backlinks,omitempty"` +} + +// ArticleSummary is a lightweight article representation for lists. +type ArticleSummary struct { + Slug string `json:"slug"` + Title string `json:"title"` + UpdatedAt time.Time `json:"updated_at"` + Revision int `json:"revision"` + WordCount int `json:"word_count"` +} + +// ArticleRevision represents a single revision of an article. +type ArticleRevision struct { + ID int64 `json:"id"` + Revision int `json:"revision"` + Body string `json:"body,omitempty"` + ChangedBy string `json:"changed_by"` + CreatedAt time.Time `json:"created_at"` + WordCount int `json:"word_count"` +} + +// ArticleLink represents a [[backlink]] from one article to another. +type ArticleLink struct { + FromSlug string `json:"from_slug"` + ToSlug string `json:"to_slug"` + DisplayText string `json:"display_text,omitempty"` +} + +// MapOfContent provides a bird's-eye view of the wiki knowledge graph. +type MapOfContent struct { + Hubs []ArticleWithLinks `json:"hubs"` + Articles []ArticleSummary `json:"articles"` + Orphans []ArticleSummary `json:"orphans"` + Wanted []WantedArticle `json:"wanted"` + Total int `json:"total"` +} + +// ArticleWithLinks is a summary enriched with backlink count. +type ArticleWithLinks struct { + ArticleSummary + BacklinkCount int `json:"backlink_count"` +} + +// WantedArticle is a slug referenced via [[slug]] but not yet created. +type WantedArticle struct { + Slug string `json:"slug"` + ReferencedCount int `json:"referenced_count"` +} diff --git a/tests/integration/mcp_e2e_test.go b/tests/integration/mcp_e2e_test.go index f942eb5..5fd32bd 100644 --- a/tests/integration/mcp_e2e_test.go +++ b/tests/integration/mcp_e2e_test.go @@ -126,7 +126,7 @@ func setupEnv(t *testing.T) *testEnv { actionIndex := actions.NewIndex(actionRegistry.List()) // Create MCP server with 4 hybrid tools - mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, nil, nil, con, jsPool, actionRegistry, actionIndex, db) + mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db) t.Cleanup(func() { mcpSrv.Shutdown(context.Background()) })