feat: agent wiki — articles, revisions, backlinks, FTS search, REST API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-04-05 19:06:36 +03:00
co-authored by Claude Opus 4.6
parent 53c8ba5bb0
commit eea6176ca9
18 changed files with 1642 additions and 13 deletions
+5 -1
View File
@@ -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)
+76 -3
View File
@@ -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"})`,
}},
},
}
}
+7 -3
View File
@@ -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 {
+14
View File
@@ -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)
+100
View File
@@ -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)
}
+165
View File
@@ -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.
+3 -1
View File
@@ -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
+1
View File
@@ -52,6 +52,7 @@ func newTestHybridWithChannels(t *testing.T) (*HybridToolRegistrar, *channels.Se
nil, // searchService
nil, // reactionService
nil, // trustService
nil, // wikiService
jsPool,
actionRegistry,
actionIndex,
+4 -1
View File
@@ -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,
+3 -3
View File
@@ -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())
+5
View File
@@ -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 {
+1
View File
@@ -70,6 +70,7 @@ func newTestHybridRegistrar(t *testing.T) (*HybridToolRegistrar, *messaging.Mess
nil, // searchService
nil, // reactionService
nil, // trustService
nil, // wikiService
jsPool,
actionRegistry,
actionIndex,
+61
View File
@@ -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);
+63
View File
@@ -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)
}
+535
View File
@@ -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, &currentRevision, &currentTitle)
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, " ")
}
+531
View File
@@ -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)
}
}
}
+67
View File
@@ -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"`
}
+1 -1
View File
@@ -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())
})