Files
hyperguild/ingestion/internal/mcp/handlers.go
T
mathiasandClaude Sonnet 5 2d5bed1e90
CI / Lint / Test / Vet (pull_request) Successful in 45s
CI / Mirror to GitHub (pull_request) Skipped
feat(brain): add brain_reject — the missing third option for raw/ notes
hyperguild#81. brain_pending lists brain/raw/ notes awaiting review;
brain_promote moves one into the wiki. There was no way to say "reviewed,
not worth promoting" — the only options were promote it or let it sit
forever. Live consequence, confirmed this session: the pending queue has
grown to 11 items, the oldest 152 days old, several of which are generic
web scrapes nobody is ever going to promote but nobody could clear either
except an un-audited rm bypassing the tool surface entirely.

RejectNote (internal/api/promote.go) mirrors PromoteNote's exact safety
shape: same basename-only filename validation, same write-then-delete
ordering (the source survives any write failure), same collision check
(never silently overwrite an existing rejected note under the same
name). Stamps rejected_at (+ reject_reason if given) into frontmatter,
moves brain/raw/<filename> -> brain/raw/rejected/<filename>. No index
rebuild or auto-tunnel -- a rejected note never entered the wiki, so
there's nothing to index. raw/rejected/ is a subdirectory ListPending's
non-recursive os.ReadDir never walks into, so a rejected note is never
re-surfaced as pending -- the human decision, once made, stays made.

brain_reject wired into the MCP tool list and dispatch, mirroring
brain_promote's schema shape (filename required, reason optional).
Human-invoked only, same as promote -- never called automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:59:43 +02:00

612 lines
23 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
)
// tools returns the tool descriptors. Handler bodies for each tool are filled
// in subsequent tasks; this file currently only provides the descriptors.
func (s *Server) tools() []map[string]any {
str := func(desc string) map[string]any {
return map[string]any{"type": "string", "description": desc}
}
int_ := func(desc string) map[string]any {
return map[string]any{"type": "integer", "description": desc}
}
enum := func(desc string, vals ...string) map[string]any {
return map[string]any{"type": "string", "description": desc, "enum": vals}
}
halls := []string{"facts", "decisions", "failures", "hypotheses", "sources"}
schema := func(required []string, props map[string]any) json.RawMessage {
b, _ := json.Marshal(map[string]any{
"type": "object", "required": required, "properties": props,
})
return b
}
tools := []map[string]any{
{
"name": "brain_query",
"description": "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files. Optionally scope by wing (topic domain) and hall (memory type).",
"inputSchema": schema([]string{"query"}, map[string]any{
"query": str("search terms"),
"limit": int_("max results, default 5"),
"wing": str("optional wing to scope to, e.g. jepa-fx"),
"hall": enum("optional hall to scope to (requires wing)", halls...),
}),
},
{
"name": "brain_write",
"description": "Write a markdown note to brain/wiki/<wing>/<hall>/ with wing/hall/created_at frontmatter. Both wing and hall are required.",
"inputSchema": schema([]string{"content", "wing", "hall"}, map[string]any{
"content": str("markdown content"),
"filename": str("optional filename or slug"),
"wing": str("topic domain, e.g. jepa-fx"),
"hall": enum("memory type", halls...),
}),
},
{
"name": "brain_update",
"description": "Supersede an existing brain note in place: whole-note body replace + frontmatter re-stamp (updated_at, supersedes=prior content hash, supersede_reason). Errors if the target does not exist — use brain_write to create. Returns {id, path, content_hash, superseded}. Prior version recoverable from git.",
"inputSchema": schema([]string{"content"}, map[string]any{
"content": str("new full body (whole-note replace)"),
"slug": str("target note slug within wing/hall, OR a full brain-relative path (e.g. wiki/jepa-fx/facts/x.md)"),
"wing": str("wing of the target (required unless slug/path is a full path)"),
"hall": enum("hall of the target (required unless slug/path is a full path)", halls...),
"path": str("full brain-relative path to the target; takes precedence over slug/wing/hall"),
"reason": str("optional short note on why superseded — stamped into frontmatter"),
}),
},
{
"name": "brain_get",
"description": "Fetch a single brain note by id or path (both are the brain-relative path — the note handle). Returns {id, path, content_hash, frontmatter, body}. Read-after-write confirmation without a lexical re-query.",
"inputSchema": schema([]string{}, map[string]any{
"id": str("note id (brain-relative path) as returned by brain_write/brain_update"),
"path": str("brain-relative path to the note; equivalent to id"),
}),
},
{
"name": "brain_pending",
"description": "List notes in brain/raw/ awaiting human promotion to the wiki, oldest-first. Returns filename, created_at, size_bytes, excerpt. The human-review queue complement to brain_promote.",
"inputSchema": schema([]string{}, map[string]any{}),
},
{
"name": "brain_promote",
"description": "Promote a brain/raw/ note into brain/wiki/<wing>/<hall>/: rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at + custom fields), deletes the source, rebuilds the wing index, runs auto-tunnel. Errors (without touching the fs) on invalid hall or a slug collision. Returns {path}.",
"inputSchema": schema([]string{"filename", "wing", "hall"}, map[string]any{
"filename": str("basename in brain/raw/, e.g. 2026-06-01-lejpa-decision.md"),
"wing": str("target wing, e.g. jepa-fx"),
"hall": enum("target hall", halls...),
"slug": str("optional target slug; defaults to filename minus date prefix"),
}),
},
{
"name": "brain_reject",
"description": "Reject a brain/raw/ note: stamps rejected_at (+ reject_reason if given) into frontmatter and moves it to brain/raw/rejected/ — never deleted outright, and never re-surfaced by brain_pending. The third option alongside promote/leave-pending; a human decision, never automatic. Returns {path}.",
"inputSchema": schema([]string{"filename"}, map[string]any{
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
"reason": str("optional; why this isn't worth promoting"),
}),
},
noteToolDescriptors()[0],
noteToolDescriptors()[1],
{
"name": "brain_tunnel",
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
"inputSchema": schema([]string{"source", "target"}, map[string]any{
"source": str("path of source note relative to brain dir, e.g. wiki/jepa-fx/decisions/val-vol.md"),
"target": str("path of target note (must be in a different wing)"),
}),
},
{
"name": "brain_ingest_raw",
"description": "Ingest pre-structured pages into the brain wiki, bypassing the LLM extraction step.",
"inputSchema": schema([]string{"source", "pages"}, map[string]any{
"source": str("source name"),
"pages": map[string]any{"type": "array"},
"dry_run": map[string]any{"type": "boolean"},
}),
},
{
"name": "brain_ingest",
"description": "Ingest content into the brain wiki via the LLM extraction pipeline.",
"inputSchema": schema([]string{}, map[string]any{
"content": str("raw content; required when path is empty"),
"source": str("source name; required when path is empty"),
"path": str("file path; mutually exclusive with content+source"),
"dry_run": map[string]any{"type": "boolean"},
}),
},
{
"name": "brain_answer",
"description": "Retrieve relevant brain content via BM25 and synthesize a coherent answer using an LLM.",
"inputSchema": schema([]string{"query"}, map[string]any{
"query": str("question to answer"),
}),
},
{
"name": "brain_classify",
"description": "Classify raw text into doc type, title, and tags using an LLM.",
"inputSchema": schema([]string{"text"}, map[string]any{
"text": str("raw document text to classify (first 3000 chars used)"),
}),
},
{
"name": "brain_graph",
"description": "Query the brain knowledge graph (entities + wikilink edges). Op selects the traversal: neighbors (1-hop outgoing from slug), subgraph (every reachable slug within depth hops), or path (shortest directed path src→dst). Returns slug + entity metadata + edge_type + hop distance.",
"inputSchema": schema([]string{"op"}, map[string]any{
"op": enum("traversal kind", "neighbors", "subgraph", "path"),
"slug": str("origin slug for op=neighbors or op=subgraph"),
"src": str("source slug for op=path"),
"dst": str("destination slug for op=path"),
"edge_type": str("optional edge type filter for op=neighbors (e.g. wikilink); empty matches all"),
"limit": int_("max neighbors to return for op=neighbors, default 25"),
"depth": int_("max traversal depth for op=subgraph (default 2, clamped to 6) and op=path (default 4, clamped to 8)"),
}),
},
{
"name": "brain_context",
"description": "Return top-N relevant brain entries for a project context. Use at session start or before a complex task to load prior decisions, corrections, and surprises.",
"inputSchema": schema([]string{"project_root"}, map[string]any{
"project_root": str("absolute path to the project root"),
"recent_files": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
"description": "optional: recent file paths in the project to bias relevance",
},
"limit": int_("max entries to return, default 10"),
}),
},
{
"name": "session_log",
"description": "Append a structured entry to brain/sessions/<session_id>.jsonl.",
"inputSchema": schema([]string{"session_id"}, map[string]any{
"session_id": str("session identifier"),
"skill": str("skill name"),
"phase": str("phase within the skill"),
"project_root": str("absolute project root"),
"final_status": str("pass | fail | skip (legacy: ok | error | skipped also accepted)"),
"file_path": str("optional file produced"),
"model_used": str("optional model identifier"),
"duration_ms": int_("optional duration in ms"),
"message": str("optional free-text"),
}),
},
}
// The capture relay tool (#55) is advertised only when wired via
// WithCapture — MCP-native harnesses (claude.ai, Crush, Pi, LLM Council)
// reach capture through it.
if s.capture != nil {
tools = append(tools, captureToolDescriptor())
}
return tools
}
type brainQueryArgs struct {
Query string `json:"query"`
Limit int `json:"limit,omitempty"`
Wing string `json:"wing,omitempty"`
Hall string `json:"hall,omitempty"`
}
func (s *Server) brainQuery(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a brainQueryArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.Query == "" {
return nil, fmt.Errorf("query is required")
}
if a.Limit == 0 {
a.Limit = 5
}
results, err := search.QueryContext(ctx, s.brainDir, search.QueryOptions{
Query: a.Query,
Limit: a.Limit,
Wing: a.Wing,
Hall: a.Hall,
Vector: s.vector,
Embedder: s.embedder,
})
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
return json.Marshal(map[string]any{"results": results})
}
type brainWriteArgs struct {
Content string `json:"content"`
Filename string `json:"filename,omitempty"`
Wing string `json:"wing,omitempty"`
Hall string `json:"hall,omitempty"`
}
// unlinkedNote explains the `unlinked` field to the caller that caused it.
// The write landed; what is gone is the [[...]] around a target that names no
// brain entry. Said here rather than only in a doc comment because the caller
// is a model that will otherwise repeat it on the next write.
const unlinkedNote = "these [[wikilink]] targets resolve to no brain entry, " +
"so they were unwrapped to plain text. Link only entries that exist " +
"(a filename under knowledge/ or wiki/); write the target entry first if " +
"you want the link, and keep skills, repos and agent-local memory files as prose."
func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a brainWriteArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
// Delegate to the shared BrainStore so write+index+tunnel+graph live in
// one implementation (capture uses the same store). The read-after-write
// handle {id, path, content_hash} comes back from the store; path is kept
// for backward compatibility.
ref, err := s.store.Write(ctx, capture.Note{
Content: a.Content,
Filename: a.Filename,
Wing: a.Wing,
Hall: a.Hall,
})
if err != nil {
return nil, err
}
out := map[string]any{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash}
if len(ref.Unlinked) > 0 {
out["unlinked"] = ref.Unlinked
out["unlinked_note"] = unlinkedNote
}
return json.Marshal(out)
}
type brainUpdateArgs struct {
Slug string `json:"slug,omitempty"`
Wing string `json:"wing,omitempty"`
Hall string `json:"hall,omitempty"`
Path string `json:"path,omitempty"`
Content string `json:"content"`
Reason string `json:"reason,omitempty"`
}
// brainUpdate supersedes an existing note in place: whole-note body
// replace, frontmatter re-stamp (updated_at/supersedes/supersede_reason),
// graph re-index, and wing _index rebuild. It never creates — a missing
// target is an error so the caller can fall back to brain_write.
//
// Embedding re-sync is delegated to the out-of-band vectorstore.Sync
// ticker: the rewritten file's mtime advances, so the next pass re-embeds
// it. This mirrors brain_write, which likewise does not embed in-handler.
func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a brainUpdateArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.Content == "" {
return nil, fmt.Errorf("content is required")
}
// path takes precedence over slug; the store treats any slug containing
// a slash as a full brain-relative path (issue #45: "slug ... OR path").
slug := a.Slug
if a.Path != "" {
slug = a.Path
}
ref, err := s.store.Update(ctx, slug, capture.Note{
Content: a.Content,
Wing: a.Wing,
Hall: a.Hall,
Reason: a.Reason,
})
if err != nil {
return nil, err
}
out := map[string]any{
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
}
if len(ref.Unlinked) > 0 {
out["unlinked"] = ref.Unlinked
out["unlinked_note"] = unlinkedNote
}
return json.Marshal(out)
}
type brainGetArgs struct {
ID string `json:"id,omitempty"`
Path string `json:"path,omitempty"`
}
// brainGet fetches a note by id or path (both are the brainDir-relative
// path — the de-facto handle). Read-only; the create-path read-after-
// write primitive that lets callers confirm a write landed without a
// lexical re-query.
func (s *Server) brainGet(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a brainGetArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
target := a.Path
if target == "" {
target = a.ID
}
if target == "" {
return nil, fmt.Errorf("id or path is required")
}
// Containment: the store resolves any brain-relative path, and
// parseFrontmatter returns the WHOLE file as body when there is no
// frontmatter fence — so without this, brain_get reads verbatim session
// transcripts under archive/ and sessions/, which BM25 never indexes and
// which nobody therefore thinks of as reachable. Same allowlist as
// brain_read (brain#4).
if _, err := notes.Resolve(s.brainDir, target, notes.ScopeNotes); err != nil {
return nil, err
}
note, err := s.store.Get(ctx, target)
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{
"id": note.ID, "path": note.Path, "content_hash": note.ContentHash,
"frontmatter": note.Frontmatter, "body": note.Body,
})
}
// brainPending lists the raw/ review queue (oldest-first).
func (s *Server) brainPending(_ context.Context, _ json.RawMessage) (json.RawMessage, error) {
pending, err := api.ListPending(s.brainDir)
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{"pending": pending})
}
type brainPromoteArgs struct {
Filename string `json:"filename"`
Wing string `json:"wing"`
Hall string `json:"hall"`
Slug string `json:"slug,omitempty"`
}
// brainPromote moves a raw/ note into the structured wiki (frontmatter
// rewrite + index + auto-tunnel, all owned by api.PromoteNote) and then
// re-indexes it into the graph. The human-facing complement to brain_write.
func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a brainPromoteArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
})
if err != nil {
return nil, err
}
s.indexInGraph(ctx, "brain_promote", relPath)
return json.Marshal(map[string]string{"path": relPath})
}
type brainRejectArgs struct {
Filename string `json:"filename"`
Reason string `json:"reason,omitempty"`
}
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
// frontmatter-stamp + move to raw/rejected/). No graph indexing — a rejected
// note never entered the wiki, so there's nothing to index.
func (s *Server) brainReject(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a brainRejectArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason})
if err != nil {
return nil, err
}
return json.Marshal(map[string]string{"path": relPath})
}
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
// logs failures but never propagates them — the underlying write/ingest
// has already succeeded and the graph is an augmentation, not a
// correctness invariant.
func (s *Server) indexInGraph(ctx context.Context, op, relPath string) {
if s.graph == nil || relPath == "" {
return
}
if err := graphsync.IndexDoc(ctx, s.graph, s.brainDir, relPath); err != nil {
slog.Warn(op+": graph index failed", "path", relPath, "err", err)
}
}
type brainTunnelArgs struct {
Source string `json:"source"`
Target string `json:"target"`
}
func (s *Server) brainTunnel(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a brainTunnelArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.Source == "" || a.Target == "" {
return nil, fmt.Errorf("source and target are required")
}
if err := brain.WriteTunnel(s.brainDir, a.Source, a.Target); err != nil {
return nil, fmt.Errorf("tunnel: %w", err)
}
s.indexInGraph(ctx, "brain_tunnel", a.Source)
s.indexInGraph(ctx, "brain_tunnel", a.Target)
return json.Marshal(map[string]string{"status": "ok"})
}
type brainIndexArgs struct {
Wing string `json:"wing,omitempty"`
}
func (s *Server) brainIndex(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a brainIndexArgs
if len(args) > 0 {
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
}
if a.Wing == "" {
if err := brain.BuildAllWingIndexes(s.brainDir); err != nil {
return nil, fmt.Errorf("index: %w", err)
}
return json.Marshal(map[string]any{"status": "ok", "scope": "all"})
}
if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil {
return nil, fmt.Errorf("index: %w", err)
}
return json.Marshal(map[string]any{"status": "ok", "scope": a.Wing})
}
type brainIngestRawArgs struct {
Source string `json:"source"`
Pages []pipeline.RawPage `json:"pages"`
DryRun bool `json:"dry_run,omitempty"`
}
func (s *Server) brainIngestRaw(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a brainIngestRawArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.Source == "" {
return nil, fmt.Errorf("source is required")
}
if len(a.Pages) == 0 {
return nil, fmt.Errorf("pages must be non-empty")
}
result, err := pipeline.RunRaw(s.brainDir, a.Source, a.Pages, a.DryRun)
if err != nil {
return nil, fmt.Errorf("ingest: %w", err)
}
pages := result.Pages
if pages == nil {
pages = []string{}
}
warnings := result.Warnings
if warnings == nil {
warnings = []string{}
}
if !a.DryRun {
for _, p := range pages {
s.indexInGraph(ctx, "brain_ingest_raw", p)
}
}
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
}
type brainIngestArgs struct {
Content string `json:"content,omitempty"`
Source string `json:"source,omitempty"`
Path string `json:"path,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
}
func (s *Server) brainIngest(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a brainIngestArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.Path != "" && a.Content != "" {
return nil, fmt.Errorf("path and content+source are mutually exclusive")
}
if a.Path == "" && a.Content == "" {
return nil, fmt.Errorf("either path or content+source is required")
}
if s.pipeline.Complete == nil {
return nil, fmt.Errorf("LLM not configured: set INGEST_LLM_URL")
}
if a.Path != "" {
text, err := extract.Text(a.Path)
if err != nil {
return nil, fmt.Errorf("extract: %w", err)
}
source := a.Source
if source == "" {
source = filepath.Base(strings.TrimSuffix(a.Path, filepath.Ext(a.Path)))
}
return s.runIngest(ctx, text, source, a.DryRun)
}
if a.Source == "" {
return nil, fmt.Errorf("source is required when content is provided")
}
return s.runIngest(ctx, a.Content, a.Source, a.DryRun)
}
type sessionLogArgs struct {
SessionID string `json:"session_id"`
Skill string `json:"skill,omitempty"`
Phase string `json:"phase,omitempty"`
ProjectRoot string `json:"project_root,omitempty"`
FinalStatus string `json:"final_status,omitempty"`
FilePath string `json:"file_path,omitempty"`
ModelUsed string `json:"model_used,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Message string `json:"message,omitempty"`
}
func (s *Server) sessionLog(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a sessionLogArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.SessionID == "" {
return nil, fmt.Errorf("session_id is required")
}
entry := session.Entry{
SessionID: a.SessionID,
Timestamp: time.Now().UTC(),
Skill: a.Skill,
Phase: a.Phase,
ProjectRoot: a.ProjectRoot,
FinalStatus: a.FinalStatus,
FilePath: a.FilePath,
ModelUsed: a.ModelUsed,
DurationMs: a.DurationMs,
Message: a.Message,
}
dir := filepath.Join(s.brainDir, "sessions")
if err := session.Append(dir, a.SessionID, entry); err != nil {
return nil, fmt.Errorf("append: %w", err)
}
return json.Marshal(map[string]string{"status": "ok", "session_id": a.SessionID})
}
func (s *Server) runIngest(ctx context.Context, content, source string, dryRun bool) (json.RawMessage, error) {
result, err := pipeline.Run(ctx, s.pipeline, s.brainDir, content, source, dryRun)
if err != nil {
return nil, fmt.Errorf("ingest: %w", err)
}
pages := result.Pages
if pages == nil {
pages = []string{}
}
if !dryRun {
for _, p := range pages {
s.indexInGraph(ctx, "brain_ingest", p)
}
}
warnings := result.Warnings
if warnings == nil {
warnings = []string{}
}
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
}