feat: añadir sistema de knowledge por agente

Implementa una base de conocimiento persistente por agente siguiendo
el patrón pure core / impure shell:

- pkg/knowledge/: tipos puros (Document, Store interface)
- shell/knowledge/: FileStore con SQLite para indexación y archivos .md
- tools/knowledge.go: 4 tools LLM (search, read, write, list)
- tools/knowledge_test.go: tests unitarios de las tools
- internal/config/schema.go: nuevo KnowledgeToolCfg en ToolsCfg
- agents/runtime.go: inicialización del store y registro de tools
- agents/*/knowledge/about-me.md: documentos semilla para cada agente

Cada agente puede buscar, leer, crear y actualizar documentos de
conocimiento. Los archivos .md viven en agents/<id>/knowledge/ y se
indexan en SQLite (agents/<id>/data/knowledge.db).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 23:02:39 +00:00
parent 37882f1c24
commit 69607b3a65
11 changed files with 981 additions and 11 deletions
+12
View File
@@ -0,0 +1,12 @@
package shellknowledge
import (
"database/sql"
moderncsqlite "modernc.org/sqlite"
)
func init() {
// Register pure-Go SQLite driver as "sqlite3" for tests.
sql.Register("sqlite3", &moderncsqlite.Driver{})
}
+291
View File
@@ -0,0 +1,291 @@
// Package shellknowledge implements the knowledge store using files + SQLite FTS5.
package shellknowledge
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/enmanuel/agents/pkg/knowledge"
)
const ftsSchema = `
CREATE VIRTUAL TABLE IF NOT EXISTS documents USING fts5(
slug,
title,
content,
updated_at UNINDEXED
);
`
var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
// ValidSlug returns true if s is a valid document slug.
func ValidSlug(s string) bool {
if len(s) < 2 || len(s) > 64 {
return false
}
return slugRe.MatchString(s)
}
// FileStore implements knowledge.Store using markdown files + SQLite FTS5 index.
type FileStore struct {
dir string // path to agents/<id>/knowledge/
dbPath string // path to agents/<id>/data/knowledge.db
db *sql.DB
logger *slog.Logger
}
// New creates a FileStore. It ensures the knowledge dir and DB dir exist,
// opens the SQLite database, and creates the FTS5 table if needed.
func New(dir, dbPath string, logger *slog.Logger) (*FileStore, error) {
log := logger.With("component", "knowledge", "dir", dir, "db_path", dbPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create knowledge dir: %w", err)
}
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
return nil, fmt.Errorf("create knowledge db dir: %w", err)
}
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("open knowledge db: %w", err)
}
if _, err := db.Exec(ftsSchema); err != nil {
db.Close()
return nil, fmt.Errorf("create knowledge fts5 table: %w", err)
}
log.Info("knowledge_store_ready")
return &FileStore{dir: dir, dbPath: dbPath, db: db, logger: log}, nil
}
// Sync re-indexes all .md files from disk into the FTS5 table.
func (s *FileStore) Sync(ctx context.Context) error {
entries, err := os.ReadDir(s.dir)
if err != nil {
return fmt.Errorf("read knowledge dir: %w", err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin sync tx: %w", err)
}
defer tx.Rollback()
// Clear existing index
if _, err := tx.ExecContext(ctx, `DELETE FROM documents`); err != nil {
return fmt.Errorf("clear fts5 index: %w", err)
}
count := 0
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
slug := strings.TrimSuffix(e.Name(), ".md")
if !ValidSlug(slug) {
s.logger.Warn("skipping invalid slug", "file", e.Name())
continue
}
content, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
if err != nil {
s.logger.Warn("skipping unreadable file", "file", e.Name(), "err", err)
continue
}
info, err := e.Info()
if err != nil {
s.logger.Warn("skipping file without info", "file", e.Name(), "err", err)
continue
}
title := extractTitle(string(content), slug)
mtime := info.ModTime().UTC().Format(time.RFC3339)
if _, err := tx.ExecContext(ctx,
`INSERT INTO documents (slug, title, content, updated_at) VALUES (?, ?, ?, ?)`,
slug, title, string(content), mtime,
); err != nil {
s.logger.Warn("failed to index file", "slug", slug, "err", err)
continue
}
count++
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit sync tx: %w", err)
}
s.logger.Info("knowledge_sync", "count", count)
return nil
}
// Search performs full-text search on the FTS5 index.
func (s *FileStore) Search(ctx context.Context, query string, limit int) ([]knowledge.SearchResult, error) {
if limit <= 0 {
limit = 5
}
rows, err := s.db.QueryContext(ctx,
`SELECT slug, title, snippet(documents, 2, '**', '**', '…', 32), rank
FROM documents WHERE documents MATCH ?
ORDER BY rank LIMIT ?`,
query, limit,
)
if err != nil {
return nil, fmt.Errorf("knowledge search: %w", err)
}
defer rows.Close()
var results []knowledge.SearchResult
for rows.Next() {
var r knowledge.SearchResult
if err := rows.Scan(&r.Slug, &r.Title, &r.Snippet, &r.Rank); err != nil {
return nil, err
}
results = append(results, r)
}
return results, rows.Err()
}
// Get reads a document from disk by slug.
func (s *FileStore) Get(ctx context.Context, slug string) (*knowledge.Document, error) {
if !ValidSlug(slug) {
return nil, fmt.Errorf("invalid slug: %q", slug)
}
path := filepath.Join(s.dir, slug+".md")
content, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("document not found: %q", slug)
}
return nil, fmt.Errorf("read document: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat document: %w", err)
}
return &knowledge.Document{
Slug: slug,
Title: extractTitle(string(content), slug),
Content: string(content),
UpdatedAt: info.ModTime().UTC(),
}, nil
}
// Put writes a document to disk and updates the FTS5 index.
func (s *FileStore) Put(ctx context.Context, doc knowledge.Document) error {
if !ValidSlug(doc.Slug) {
return fmt.Errorf("invalid slug: %q", doc.Slug)
}
if len(doc.Content) > 64*1024 {
return fmt.Errorf("document too large: %d bytes (max 65536)", len(doc.Content))
}
path := filepath.Join(s.dir, doc.Slug+".md")
if err := os.WriteFile(path, []byte(doc.Content), 0o644); err != nil {
return fmt.Errorf("write document: %w", err)
}
title := extractTitle(doc.Content, doc.Slug)
now := time.Now().UTC().Format(time.RFC3339)
// Upsert: delete old + insert new (FTS5 doesn't support UPDATE well)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin put tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM documents WHERE slug = ?`, doc.Slug); err != nil {
return fmt.Errorf("delete old index: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO documents (slug, title, content, updated_at) VALUES (?, ?, ?, ?)`,
doc.Slug, title, doc.Content, now,
); err != nil {
return fmt.Errorf("insert index: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit put tx: %w", err)
}
s.logger.Debug("knowledge_put", "slug", doc.Slug, "size", len(doc.Content))
return nil
}
// Delete removes a document from disk and the FTS5 index.
func (s *FileStore) Delete(ctx context.Context, slug string) error {
if !ValidSlug(slug) {
return fmt.Errorf("invalid slug: %q", slug)
}
path := filepath.Join(s.dir, slug+".md")
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove document: %w", err)
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM documents WHERE slug = ?`, slug); err != nil {
return fmt.Errorf("delete from index: %w", err)
}
s.logger.Debug("knowledge_delete", "slug", slug)
return nil
}
// List returns all documents from the FTS5 index.
func (s *FileStore) List(ctx context.Context) ([]knowledge.Document, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT slug, title, updated_at FROM documents ORDER BY slug`)
if err != nil {
return nil, fmt.Errorf("knowledge list: %w", err)
}
defer rows.Close()
var docs []knowledge.Document
for rows.Next() {
var d knowledge.Document
var updatedAt string
if err := rows.Scan(&d.Slug, &d.Title, &updatedAt); err != nil {
return nil, err
}
d.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
docs = append(docs, d)
}
return docs, rows.Err()
}
// Close releases the SQLite database.
func (s *FileStore) Close() error {
s.logger.Info("knowledge_store_closed")
return s.db.Close()
}
// extractTitle returns the first H1 heading from markdown content, or a humanized slug.
func extractTitle(content, slug string) string {
for _, line := range strings.SplitN(content, "\n", 20) {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "# ") {
return strings.TrimPrefix(line, "# ")
}
}
// Humanize slug: "go-patterns" → "Go patterns"
humanized := strings.ReplaceAll(slug, "-", " ")
if len(humanized) > 0 {
humanized = strings.ToUpper(humanized[:1]) + humanized[1:]
}
return humanized
}
+208
View File
@@ -0,0 +1,208 @@
package shellknowledge
import (
"context"
"os"
"path/filepath"
"testing"
"log/slog"
"github.com/enmanuel/agents/pkg/knowledge"
)
func testStore(t *testing.T) (*FileStore, string) {
t.Helper()
dir := t.TempDir()
knowledgeDir := filepath.Join(dir, "knowledge")
dbPath := filepath.Join(dir, "data", "knowledge.db")
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
store, err := New(knowledgeDir, dbPath, logger)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
return store, knowledgeDir
}
func TestValidSlug(t *testing.T) {
tests := []struct {
slug string
want bool
}{
{"go-patterns", true},
{"ab", true},
{"a-b", true},
{"abc123", true},
{"a", false}, // too short
{"A-B", false}, // uppercase
{"-bad", false}, // starts with hyphen
{"bad-", false}, // ends with hyphen
{"has space", false}, // space
{"has_underscore", false}, // underscore
{"", false},
}
for _, tt := range tests {
if got := ValidSlug(tt.slug); got != tt.want {
t.Errorf("ValidSlug(%q) = %v, want %v", tt.slug, got, tt.want)
}
}
}
func TestPutAndGet(t *testing.T) {
store, _ := testStore(t)
ctx := context.Background()
doc := knowledge.Document{
Slug: "test-doc",
Content: "# Test Document\n\nThis is a test.",
}
if err := store.Put(ctx, doc); err != nil {
t.Fatal(err)
}
got, err := store.Get(ctx, "test-doc")
if err != nil {
t.Fatal(err)
}
if got.Content != doc.Content {
t.Errorf("content mismatch: got %q, want %q", got.Content, doc.Content)
}
if got.Title != "Test Document" {
t.Errorf("title = %q, want %q", got.Title, "Test Document")
}
}
func TestPutInvalidSlug(t *testing.T) {
store, _ := testStore(t)
ctx := context.Background()
err := store.Put(ctx, knowledge.Document{Slug: "BAD", Content: "test"})
if err == nil {
t.Error("expected error for invalid slug")
}
}
func TestPutTooLarge(t *testing.T) {
store, _ := testStore(t)
ctx := context.Background()
bigContent := make([]byte, 65*1024)
for i := range bigContent {
bigContent[i] = 'x'
}
err := store.Put(ctx, knowledge.Document{Slug: "too-big", Content: string(bigContent)})
if err == nil {
t.Error("expected error for oversized document")
}
}
func TestSyncAndSearch(t *testing.T) {
store, knowledgeDir := testStore(t)
ctx := context.Background()
// Write files directly to disk
os.WriteFile(filepath.Join(knowledgeDir, "go-patterns.md"),
[]byte("# Go Patterns\n\nUse interfaces for dependency injection."), 0o644)
os.WriteFile(filepath.Join(knowledgeDir, "matrix-tips.md"),
[]byte("# Matrix Tips\n\nUse mautrix-go for Matrix bots."), 0o644)
if err := store.Sync(ctx); err != nil {
t.Fatal(err)
}
// Search for "interfaces"
results, err := store.Search(ctx, "interfaces", 5)
if err != nil {
t.Fatal(err)
}
if len(results) == 0 {
t.Fatal("expected at least 1 search result")
}
if results[0].Slug != "go-patterns" {
t.Errorf("expected slug go-patterns, got %q", results[0].Slug)
}
}
func TestList(t *testing.T) {
store, _ := testStore(t)
ctx := context.Background()
// Empty initially
docs, err := store.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(docs) != 0 {
t.Errorf("expected 0 docs, got %d", len(docs))
}
// Add two docs
store.Put(ctx, knowledge.Document{Slug: "alpha", Content: "# Alpha\nContent A"})
store.Put(ctx, knowledge.Document{Slug: "beta", Content: "# Beta\nContent B"})
docs, err = store.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(docs) != 2 {
t.Fatalf("expected 2 docs, got %d", len(docs))
}
}
func TestDelete(t *testing.T) {
store, knowledgeDir := testStore(t)
ctx := context.Background()
store.Put(ctx, knowledge.Document{Slug: "to-delete", Content: "# Delete Me\nGoodbye"})
// Verify file exists
if _, err := os.Stat(filepath.Join(knowledgeDir, "to-delete.md")); err != nil {
t.Fatal("file should exist after Put")
}
if err := store.Delete(ctx, "to-delete"); err != nil {
t.Fatal(err)
}
// File removed
if _, err := os.Stat(filepath.Join(knowledgeDir, "to-delete.md")); !os.IsNotExist(err) {
t.Error("file should be removed after Delete")
}
// Not in index
_, err := store.Get(ctx, "to-delete")
if err == nil {
t.Error("expected error for deleted document")
}
}
func TestGetNotFound(t *testing.T) {
store, _ := testStore(t)
ctx := context.Background()
_, err := store.Get(ctx, "nonexistent")
if err == nil {
t.Error("expected error for nonexistent document")
}
}
func TestExtractTitle(t *testing.T) {
tests := []struct {
content string
slug string
want string
}{
{"# My Title\nBody", "slug", "My Title"},
{"No heading here", "my-doc", "My doc"},
{"", "empty-doc", "Empty doc"},
{"\n\n# Late Title\n", "slug", "Late Title"},
}
for _, tt := range tests {
got := extractTitle(tt.content, tt.slug)
if got != tt.want {
t.Errorf("extractTitle(%q, %q) = %q, want %q", tt.content, tt.slug, got, tt.want)
}
}
}