8 Commits

Author SHA1 Message Date
9e6d05e055 feat(ui): add content editor components for skills and thoughts
Some checks failed
CI / build-and-test (push) Failing after -31m24s
* Implement ContentEditorField for inline editing of content
* Create ContentEditorModal for editing content in a modal
* Introduce FormerShell for managing forms related to skills and thoughts
* Enhance SkillsPage and ThoughtsPage with new components for better content management
2026-05-02 19:35:27 +02:00
Hein
442cc3ef53 style(ui): update layout from grid to flex for consistency
Some checks failed
CI / build-and-test (push) Failing after -31m52s
2026-04-30 21:02:37 +02:00
Hein
5e54167009 fix(db): update SQL default values and types for consistency
Some checks failed
CI / build-and-test (push) Failing after -31m34s
* Corrected default values for various fields in SQL schema
* Changed tags field type from text[] to text[] with proper default
* Updated JSONB default values to remove unnecessary quotes
2026-04-30 20:31:34 +02:00
Hein
65715f7ad3 fix: remove redundant code in processing logic
Some checks failed
CI / build-and-test (push) Failing after -31m35s
2026-04-30 16:04:04 +02:00
537e65ea6d feat(ui): implement public status endpoint and update UI components
Some checks failed
CI / build-and-test (push) Failing after -30m49s
* add public status handler and response types
* modify status API to restrict access and update client tracking
* adjust UI components to display public status information
* update routing to include public status endpoint
2026-04-27 00:23:06 +02:00
e208c62df3 feat(makefile): add release version handling and remote option
Some checks failed
CI / build-and-test (push) Failing after -32m12s
2026-04-27 00:09:45 +02:00
f6a86e3933 .
Some checks failed
CI / build-and-test (push) Failing after -32m5s
2026-04-27 00:04:11 +02:00
a4193b295a fix(ui): update AMCS references and add status handling
* Corrected "Advanced Module Control System" to "Avalon Memory Control Service" in documentation and UI components.
* Added status handling to the LoginInfoPanel and LoginPage components.
* Implemented new endpoints for robots.txt and llms.txt.
2026-04-27 00:04:08 +02:00
82 changed files with 7979 additions and 3480 deletions

3
.gitignore vendored
View File

@@ -33,5 +33,6 @@ bin/
OB1/
ui/node_modules/
ui/.svelte-kit/
internal/app/ui/dist/
internal/app/ui/dist/*
!internal/app/ui/dist/placeholder.txt
.codex

View File

@@ -4,7 +4,10 @@ SERVER_BIN := $(BIN_DIR)/amcs-server
CMD_SERVER := ./cmd/amcs-server
BUILDINFO_PKG := git.warky.dev/wdevs/amcs/internal/buildinfo
UI_DIR := $(CURDIR)/ui
AMCS_UI_BACKEND ?= http://127.0.0.1:8080
PATCH_INCREMENT ?= 1
RELEASE_VERSION ?=
RELEASE_REMOTE ?= origin
VERSION_TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo dev)
COMMIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
@@ -20,7 +23,7 @@ LDFLAGS := -s -w \
-X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \
-X $(BUILDINFO_PKG).BuildDate=$(BUILD_DATE)
.PHONY: all build clean migrate release-version test generate-migrations generate-models check-schema-drift build-cli ui-install ui-build ui-dev ui-check help
.PHONY: all build clean migrate release-version release-build test generate-migrations generate-models check-schema-drift build-cli ui-install ui-build ui-dev ui-check help
all: build
@@ -31,13 +34,14 @@ help:
@echo " test Run all tests (includes UI check)"
@echo " clean Remove build artifacts"
@echo " migrate Run database migrations"
@echo " release-version Tag and push a new patch release (PATCH_INCREMENT=N)"
@echo " release-version Tag and push a release (auto patch bump or RELEASE_VERSION=vX.Y.Z)"
@echo " release-build Build with a specific release tag (RELEASE_VERSION=vX.Y.Z)"
@echo " generate-migrations Generate SQL migration from DBML schema files"
@echo " generate-models Generate Go models from DBML schema"
@echo " check-schema-drift Verify generated migration matches current schema"
@echo " ui-install Install UI dependencies"
@echo " ui-build Build UI assets"
@echo " ui-dev Start UI dev server"
@echo " ui-dev Start UI dev server with local API proxy"
@echo " ui-check Run UI type checks"
build: ui-build
@@ -51,7 +55,7 @@ ui-build: ui-install
cd $(UI_DIR) && $(PNPM) run build
ui-dev: ui-install
cd $(UI_DIR) && $(PNPM) run dev
cd $(UI_DIR) && VITE_API_URL=/api AMCS_UI_BACKEND=$(AMCS_UI_BACKEND) $(PNPM) run dev
ui-check: ui-install
cd $(UI_DIR) && $(PNPM) run check
@@ -64,22 +68,41 @@ release-version:
@case "$(PATCH_INCREMENT)" in \
''|*[!0-9]*|0) echo "PATCH_INCREMENT must be a positive integer" >&2; exit 1 ;; \
esac
@latest=$$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n 1); \
if [ -z "$$latest" ]; then latest="v0.0.0"; fi; \
version=$${latest#v}; \
major=$${version%%.*}; \
rest=$${version#*.}; \
minor=$${rest%%.*}; \
patch=$${rest##*.}; \
next_patch=$$((patch + $(PATCH_INCREMENT))); \
next_tag="v$$major.$$minor.$$next_patch"; \
@if ! git diff --quiet || ! git diff --cached --quiet; then \
echo "Refusing to release from a dirty working tree. Commit or stash changes first." >&2; \
exit 1; \
fi
@next_tag="$(RELEASE_VERSION)"; \
if [ -z "$$next_tag" ]; then \
latest=$$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n 1); \
if [ -z "$$latest" ]; then latest="v0.0.0"; fi; \
version=$${latest#v}; \
major=$${version%%.*}; \
rest=$${version#*.}; \
minor=$${rest%%.*}; \
patch=$${rest##*.}; \
next_patch=$$((patch + $(PATCH_INCREMENT))); \
next_tag="v$$major.$$minor.$$next_patch"; \
fi; \
case "$$next_tag" in \
v[0-9]*.[0-9]*.[0-9]*) ;; \
*) echo "RELEASE_VERSION must look like vX.Y.Z (got '$$next_tag')" >&2; exit 1 ;; \
esac; \
if git rev-parse -q --verify "refs/tags/$$next_tag" >/dev/null; then \
echo "$$next_tag already exists" >&2; \
exit 1; \
fi; \
git tag -a "$$next_tag" -m "Release $$next_tag"; \
git push origin "$$next_tag"; \
echo "$$next_tag"
git push $(RELEASE_REMOTE) "$$next_tag"; \
$(MAKE) release-build RELEASE_VERSION="$$next_tag"; \
echo "Released $$next_tag"
release-build:
@case "$(RELEASE_VERSION)" in \
v[0-9]*.[0-9]*.[0-9]*) ;; \
*) echo "RELEASE_VERSION must look like vX.Y.Z" >&2; exit 1 ;; \
esac
@$(MAKE) build build-cli VERSION_TAG="$(RELEASE_VERSION)"
migrate:
./scripts/migrate.sh

View File

@@ -1,10 +1,10 @@
# AMCS Directory
This is the AMCS (Advanced Module Control System) directory.
This is the AMCS (Avalon Memory Control Service) directory.
## Purpose
The AMCS directory is used to store configuration and code for the Advanced Module Control System, which handles...
The AMCS directory is used to store configuration and code for the Avalon Memory Control Service, which handles...
## Structure

2
go.mod
View File

@@ -3,7 +3,7 @@ module git.warky.dev/wdevs/amcs
go 1.26.1
require (
github.com/bitechdev/ResolveSpec v1.0.86
github.com/bitechdev/ResolveSpec v1.0.87
github.com/google/jsonschema-go v0.4.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1

2
go.sum
View File

@@ -36,6 +36,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bitechdev/ResolveSpec v1.0.86 h1:a4yFMMDizrmvDOV61cj/+kD+mEtKL/5EIHY2GcP3uJU=
github.com/bitechdev/ResolveSpec v1.0.86/go.mod h1:YZOY2YCD0Kmb+pjAMhOqPh4q82Hij57F/CLlCMkzT78=
github.com/bitechdev/ResolveSpec v1.0.87 h1:zLiHynLK8LLpXIfCZOjL5Iy1COBS6YZcWE1BHKfYqbA=
github.com/bitechdev/ResolveSpec v1.0.87/go.mod h1:YZOY2YCD0Kmb+pjAMhOqPh4q82Hij57F/CLlCMkzT78=
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I=
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=

View File

@@ -205,6 +205,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
Projects: tools.NewProjectsTool(db, activeProjects),
Version: tools.NewVersionTool(cfg.MCP.ServerName, info),
Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search),
Plans: tools.NewPlansTool(db, activeProjects, cfg.Search),
Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects),
Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects),
Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects),
@@ -213,10 +214,10 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
Backfill: backfillTool,
Reparse: tools.NewReparseMetadataTool(db, bgMetadata, cfg.Capture, activeProjects, logger),
RetryMetadata: tools.NewRetryEnrichmentTool(enrichmentRetryer),
Maintenance: tools.NewMaintenanceTool(db),
Skills: tools.NewSkillsTool(db, activeProjects),
ChatHistory: tools.NewChatHistoryTool(db, activeProjects),
Describe: tools.NewDescribeTool(db, mcpserver.BuildToolCatalog()),
//Maintenance: tools.NewMaintenanceTool(db),
Skills: tools.NewSkillsTool(db, activeProjects),
ChatHistory: tools.NewChatHistoryTool(db, activeProjects),
Describe: tools.NewDescribeTool(db, mcpserver.BuildToolCatalog()),
}
mcpHandlers, err := mcpserver.NewHandlers(cfg.MCP, logger, toolSet, activeProjects.Clear)
@@ -243,7 +244,11 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
mux.HandleFunc("/images/project.jpg", serveHomeImage)
mux.HandleFunc("/images/icon.png", serveIcon)
mux.HandleFunc("/llm", serveLLMInstructions)
mux.HandleFunc("/api/status", statusAPIHandler(info, accessTracker, oauthEnabled))
mux.HandleFunc("/llms.txt", serveLLMSTXT)
mux.HandleFunc("/.well-known/llms.txt", serveLLMSTXT)
mux.HandleFunc("/robots.txt", serveRobotsTXT)
mux.Handle("/api/status", authMiddleware(statusAPIHandler(info, accessTracker, oauthEnabled)))
mux.HandleFunc("/status", publicStatusHandler(accessTracker))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)

View File

@@ -1,7 +1,9 @@
package app
import (
"fmt"
"net/http"
"strings"
amcsllm "git.warky.dev/wdevs/amcs/llm"
)
@@ -20,3 +22,74 @@ func serveLLMInstructions(w http.ResponseWriter, r *http.Request) {
}
_, _ = w.Write(amcsllm.MemoryInstructions)
}
func serveRobotsTXT(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/robots.txt" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
body := fmt.Sprintf("User-agent: *\nAllow: /\n\n# LLM-friendly docs\nLLM: %s/llm\nLLMS: %s/llms.txt\n", requestBaseURL(r), requestBaseURL(r))
_, _ = w.Write([]byte(body))
}
func serveLLMSTXT(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/llms.txt" && r.URL.Path != "/.well-known/llms.txt" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
base := requestBaseURL(r)
body := fmt.Sprintf(
"# AMCS\n\n> A memory server for AI assistants (MCP tools, semantic retrieval, and structured project memory).\n\n## Endpoints\n- %s/llm\n- %s/status\n- %s/mcp\n- %s/.well-known/oauth-authorization-server\n",
base,
base,
base,
base,
)
_, _ = w.Write([]byte(body))
}
func requestBaseURL(r *http.Request) string {
scheme := "http"
if r != nil && r.TLS != nil {
scheme = "https"
}
if r != nil {
if proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); proto != "" {
scheme = proto
}
}
host := "localhost"
if r != nil {
if v := strings.TrimSpace(r.Host); v != "" {
host = v
}
}
return scheme + "://" + host
}

View File

@@ -3,6 +3,7 @@ package app
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
amcsllm "git.warky.dev/wdevs/amcs/llm"
@@ -29,3 +30,70 @@ func TestServeLLMInstructions(t *testing.T) {
t.Fatalf("body = %q, want embedded instructions", body)
}
}
func TestServeRobotsTXT(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/robots.txt", nil)
req.Host = "amcs.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
rec := httptest.NewRecorder()
serveRobotsTXT(rec, req)
res := rec.Result()
defer func() {
_ = res.Body.Close()
}()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK)
}
if got := res.Header.Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("content-type = %q, want %q", got, "text/plain; charset=utf-8")
}
body := rec.Body.String()
if !strings.Contains(body, "LLM: https://amcs.example.com/llm") {
t.Fatalf("body = %q, want LLM link", body)
}
if !strings.Contains(body, "LLMS: https://amcs.example.com/llms.txt") {
t.Fatalf("body = %q, want LLMS link", body)
}
}
func TestServeLLMSTXT(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/llms.txt", nil)
req.Host = "amcs.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
rec := httptest.NewRecorder()
serveLLMSTXT(rec, req)
res := rec.Result()
defer func() {
_ = res.Body.Close()
}()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK)
}
if got := res.Header.Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("content-type = %q, want %q", got, "text/plain; charset=utf-8")
}
body := rec.Body.String()
if !strings.Contains(body, "https://amcs.example.com/llm") {
t.Fatalf("body = %q, want /llm link", body)
}
if !strings.Contains(body, "https://amcs.example.com/.well-known/oauth-authorization-server") {
t.Fatalf("body = %q, want oauth discovery link", body)
}
}
func TestServeLLMSTXTWellKnownPath(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/.well-known/llms.txt", nil)
rec := httptest.NewRecorder()
serveLLMSTXT(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/resolvespec"
"github.com/uptrace/bunrouter"
@@ -26,6 +27,21 @@ func registerResolveSpecAdminRoutes(mux *http.ServeMux, db *store.DB, middleware
rsMount := http.StripPrefix("/api/rs", rsRouter)
protectedRSMount := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && strings.HasSuffix(r.URL.Path, "/") {
trimmed := strings.TrimRight(r.URL.Path, "/")
if trimmed == "" {
trimmed = "/"
}
clone := r.Clone(r.Context())
clone.URL.Path = trimmed
if clone.URL.RawPath != "" {
clone.URL.RawPath = strings.TrimRight(clone.URL.RawPath, "/")
if clone.URL.RawPath == "" {
clone.URL.RawPath = "/"
}
}
r = clone
}
if r.Method == http.MethodOptions {
rsMount.ServeHTTP(w, r)
return
@@ -50,15 +66,36 @@ func registerResolveSpecGuards(rs *resolvespec.Handler) {
mutableByEntity := map[string]map[string]struct{}{
"projects": {
"create": {},
"update": {},
"delete": {},
},
"thoughts": {
"create": {},
"update": {},
"delete": {},
},
"plans": {
"create": {},
"update": {},
"delete": {},
},
"learnings": {
"create": {},
"update": {},
"delete": {},
},
"agent_skills": {
"create": {},
"update": {},
"delete": {},
},
"agent_guardrails": {
"create": {},
"update": {},
"delete": {},
},
"stored_files": {
"update": {},
"delete": {},
},
}
@@ -98,28 +135,35 @@ type resolveSpecModel struct {
}
func resolveSpecModels() []resolveSpecModel {
//This must be generated with relspec to include all models
//Use the relspec command with template generation. It supprot templ.
return []resolveSpecModel{
{schema: "public", entity: "activities", model: generatedmodels.ModelPublicActivities{}},
// {schema: "public", entity: "activities", model: generatedmodels.ModelPublicActivities{}},
{schema: "public", entity: "agent_guardrails", model: generatedmodels.ModelPublicAgentGuardrails{}},
{schema: "public", entity: "agent_skills", model: generatedmodels.ModelPublicAgentSkills{}},
{schema: "public", entity: "chat_histories", model: generatedmodels.ModelPublicChatHistories{}},
{schema: "public", entity: "contact_interactions", model: generatedmodels.ModelPublicContactInteractions{}},
// {schema: "public", entity: "contact_interactions", model: generatedmodels.ModelPublicContactInteractions{}},
{schema: "public", entity: "embeddings", model: generatedmodels.ModelPublicEmbeddings{}},
{schema: "public", entity: "family_members", model: generatedmodels.ModelPublicFamilyMembers{}},
{schema: "public", entity: "household_items", model: generatedmodels.ModelPublicHouseholdItems{}},
{schema: "public", entity: "household_vendors", model: generatedmodels.ModelPublicHouseholdVendors{}},
{schema: "public", entity: "important_dates", model: generatedmodels.ModelPublicImportantDates{}},
// {schema: "public", entity: "family_members", model: generatedmodels.ModelPublicFamilyMembers{}},
// {schema: "public", entity: "household_items", model: generatedmodels.ModelPublicHouseholdItems{}},
// {schema: "public", entity: "household_vendors", model: generatedmodels.ModelPublicHouseholdVendors{}},
// {schema: "public", entity: "important_dates", model: generatedmodels.ModelPublicImportantDates{}},
{schema: "public", entity: "learnings", model: generatedmodels.ModelPublicLearnings{}},
{schema: "public", entity: "maintenance_logs", model: generatedmodels.ModelPublicMaintenanceLogs{}},
{schema: "public", entity: "maintenance_tasks", model: generatedmodels.ModelPublicMaintenanceTasks{}},
{schema: "public", entity: "meal_plans", model: generatedmodels.ModelPublicMealPlans{}},
{schema: "public", entity: "opportunities", model: generatedmodels.ModelPublicOpportunities{}},
{schema: "public", entity: "professional_contacts", model: generatedmodels.ModelPublicProfessionalContacts{}},
// {schema: "public", entity: "maintenance_logs", model: generatedmodels.ModelPublicMaintenanceLogs{}},
// {schema: "public", entity: "maintenance_tasks", model: generatedmodels.ModelPublicMaintenanceTasks{}},
// {schema: "public", entity: "meal_plans", model: generatedmodels.ModelPublicMealPlans{}},
// {schema: "public", entity: "opportunities", model: generatedmodels.ModelPublicOpportunities{}},
{schema: "public", entity: "plan_dependencies", model: generatedmodels.ModelPublicPlanDependencies{}},
{schema: "public", entity: "plan_guardrails", model: generatedmodels.ModelPublicPlanGuardrails{}},
{schema: "public", entity: "plan_related_plans", model: generatedmodels.ModelPublicPlanRelatedPlans{}},
{schema: "public", entity: "plan_skills", model: generatedmodels.ModelPublicPlanSkills{}},
{schema: "public", entity: "plans", model: generatedmodels.ModelPublicPlans{}},
// {schema: "public", entity: "professional_contacts", model: generatedmodels.ModelPublicProfessionalContacts{}},
{schema: "public", entity: "project_guardrails", model: generatedmodels.ModelPublicProjectGuardrails{}},
{schema: "public", entity: "project_skills", model: generatedmodels.ModelPublicProjectSkills{}},
{schema: "public", entity: "projects", model: generatedmodels.ModelPublicProjects{}},
{schema: "public", entity: "recipes", model: generatedmodels.ModelPublicRecipes{}},
{schema: "public", entity: "shopping_lists", model: generatedmodels.ModelPublicShoppingLists{}},
// {schema: "public", entity: "recipes", model: generatedmodels.ModelPublicRecipes{}},
// {schema: "public", entity: "shopping_lists", model: generatedmodels.ModelPublicShoppingLists{}},
{schema: "public", entity: "stored_files", model: generatedmodels.ModelPublicStoredFiles{}},
{schema: "public", entity: "thought_links", model: generatedmodels.ModelPublicThoughtLinks{}},
{schema: "public", entity: "thoughts", model: generatedmodels.ModelPublicThoughts{}},

View File

@@ -63,10 +63,25 @@ func TestResolveSpecGuardAllowsSupportedMutations(t *testing.T) {
}{
{name: "learnings read", entity: "learnings", operation: "read"},
{name: "projects create", entity: "projects", operation: "create"},
{name: "projects update", entity: "projects", operation: "update"},
{name: "projects delete", entity: "projects", operation: "delete"},
{name: "plans create", entity: "plans", operation: "create"},
{name: "plans update", entity: "plans", operation: "update"},
{name: "plans delete", entity: "plans", operation: "delete"},
{name: "learnings create", entity: "learnings", operation: "create"},
{name: "learnings update", entity: "learnings", operation: "update"},
{name: "learnings delete", entity: "learnings", operation: "delete"},
{name: "thoughts create", entity: "thoughts", operation: "create"},
{name: "thoughts update", entity: "thoughts", operation: "update"},
{name: "thoughts delete", entity: "thoughts", operation: "delete"},
{name: "agent_skills create", entity: "agent_skills", operation: "create"},
{name: "agent_skills update", entity: "agent_skills", operation: "update"},
{name: "agent_skills delete", entity: "agent_skills", operation: "delete"},
{name: "agent_guardrails create", entity: "agent_guardrails", operation: "create"},
{name: "agent_guardrails update", entity: "agent_guardrails", operation: "update"},
{name: "agent_guardrails delete", entity: "agent_guardrails", operation: "delete"},
{name: "stored_files update", entity: "stored_files", operation: "update"},
{name: "stored_files delete", entity: "stored_files", operation: "delete"},
}
for _, tc := range cases {
@@ -100,32 +115,18 @@ func TestResolveSpecGuardBlocksUnsupportedMutations(t *testing.T) {
wantMessageIn string
}{
{
name: "create not allowed on thoughts",
entity: "thoughts",
name: "mutations blocked for non-allowlisted operation",
entity: "stored_files",
operation: "create",
wantCode: http.StatusForbidden,
wantMessageIn: `operation "create" is not allowed for public.thoughts`,
},
{
name: "delete not allowed on projects",
entity: "projects",
operation: "delete",
wantCode: http.StatusForbidden,
wantMessageIn: `operation "delete" is not allowed for public.projects`,
wantMessageIn: `operation "create" is not allowed for public.stored_files`,
},
{
name: "mutations blocked for non-allowlisted entity",
entity: "stored_files",
entity: "maintenance_logs",
operation: "delete",
wantCode: http.StatusForbidden,
wantMessageIn: `operation "delete" is not allowed for public.stored_files`,
},
{
name: "mutations blocked for learnings",
entity: "learnings",
operation: "delete",
wantCode: http.StatusForbidden,
wantMessageIn: `operation "delete" is not allowed for public.learnings`,
wantMessageIn: `operation "delete" is not allowed for public.maintenance_logs`,
},
{
name: "unknown operation is rejected",

View File

@@ -29,8 +29,24 @@ type statusAPIResponse struct {
OAuthEnabled bool `json:"oauth_enabled"`
}
type publicClientStatus struct {
KeyID string `json:"key_id"`
RequestCount int `json:"request_count"`
LastAccessedAt time.Time `json:"last_accessed_at"`
}
type publicStatusResponse struct {
ConnectedCount int `json:"connected_count"`
ConnectedWindow string `json:"connected_window"`
Entries []publicClientStatus `json:"entries"`
}
func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) statusAPIResponse {
entries := tracker.Snapshot()
metrics := tracker.Metrics(20)
metrics.TopIPs = nil
metrics.TopAgents = nil
metrics.TopTools = nil
return statusAPIResponse{
Title: "Avelon Memory Crystal Server (AMCS)",
Description: "AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.",
@@ -40,8 +56,8 @@ func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabl
ConnectedCount: tracker.ConnectedCount(now, connectedWindow),
TotalKnown: len(entries),
ConnectedWindow: "last 10 minutes",
Entries: entries,
Metrics: tracker.Metrics(20),
Entries: nil,
Metrics: metrics,
OAuthEnabled: oauthEnabled,
}
}
@@ -75,6 +91,47 @@ func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEna
}
}
func publicStatusHandler(tracker *auth.AccessTracker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/status" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
now := time.Now()
cutoff := now.UTC().Add(-connectedWindow)
snapshot := tracker.Snapshot()
entries := make([]publicClientStatus, 0, len(snapshot))
for _, item := range snapshot {
if item.LastAccessedAt.Before(cutoff) {
continue
}
entries = append(entries, publicClientStatus{
KeyID: item.KeyID,
RequestCount: item.RequestCount,
LastAccessedAt: item.LastAccessedAt,
})
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
_ = json.NewEncoder(w).Encode(publicStatusResponse{
ConnectedCount: len(entries),
ConnectedWindow: "last 10 minutes",
Entries: entries,
})
}
}
func homeHandler(_ buildinfo.Info, _ *auth.AccessTracker, _ bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {

View File

@@ -43,11 +43,8 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
if snapshot.ConnectedCount != 1 {
t.Fatalf("ConnectedCount = %d, want 1", snapshot.ConnectedCount)
}
if len(snapshot.Entries) != 1 {
t.Fatalf("len(Entries) = %d, want 1", len(snapshot.Entries))
}
if snapshot.Entries[0].KeyID != "client-a" || snapshot.Entries[0].LastPath != "/files" {
t.Fatalf("entry = %+v, want keyID client-a and path /files", snapshot.Entries[0])
if len(snapshot.Entries) != 0 {
t.Fatalf("len(Entries) = %d, want 0 for counts-only status", len(snapshot.Entries))
}
if snapshot.Metrics.TotalRequests != 1 {
t.Fatalf("Metrics.TotalRequests = %d, want 1", snapshot.Metrics.TotalRequests)
@@ -61,6 +58,9 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
if snapshot.Metrics.UniqueTools != 1 {
t.Fatalf("Metrics.UniqueTools = %d, want 1", snapshot.Metrics.UniqueTools)
}
if len(snapshot.Metrics.TopIPs) != 0 || len(snapshot.Metrics.TopAgents) != 0 || len(snapshot.Metrics.TopTools) != 0 {
t.Fatalf("Top breakdowns should be hidden in counts-only status: %+v", snapshot.Metrics)
}
}
func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
@@ -86,6 +86,52 @@ func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
}
}
func TestStatusAPIHandlerRejectsStatusPath(t *testing.T) {
handler := statusAPIHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), true)
req := httptest.NewRequest(http.MethodGet, "/status", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestPublicStatusHandlerReturnsConnectedClientsOnly(t *testing.T) {
tracker := auth.NewAccessTracker()
now := time.Now().UTC()
tracker.Record("recent-client", "/mcp", "127.0.0.1:1234", "tester", "list_projects", now.Add(-2*time.Minute))
tracker.Record("stale-client", "/mcp", "127.0.0.1:9999", "tester", "list_projects", now.Add(-30*time.Minute))
handler := publicStatusHandler(tracker)
req := httptest.NewRequest(http.MethodGet, "/status", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var payload publicStatusResponse
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.ConnectedCount != 1 {
t.Fatalf("ConnectedCount = %d, want 1", payload.ConnectedCount)
}
if len(payload.Entries) != 1 {
t.Fatalf("len(Entries) = %d, want 1", len(payload.Entries))
}
if payload.Entries[0].KeyID != "recent-client" {
t.Fatalf("Entries[0].KeyID = %q, want %q", payload.Entries[0].KeyID, "recent-client")
}
if payload.Entries[0].LastAccessedAt.Before(now.Add(-11 * time.Minute)) {
t.Fatalf("Entries[0].LastAccessedAt = %v, expected recent timestamp", payload.Entries[0].LastAccessedAt)
}
}
func TestHomeHandlerAllowsHead(t *testing.T) {
handler := homeHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), false)
req := httptest.NewRequest(http.MethodHead, "/", nil)

View File

@@ -1,70 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicActivities struct {
bun.BaseModel `bun:"table:public.activities,alias:activities"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
ActivityType resolvespec_common.SqlString `bun:"activity_type,type:text,nullzero," json:"activity_type"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
DayOfWeek resolvespec_common.SqlString `bun:"day_of_week,type:text,nullzero," json:"day_of_week"`
EndDate resolvespec_common.SqlDate `bun:"end_date,type:date,nullzero," json:"end_date"`
EndTime resolvespec_common.SqlTime `bun:"end_time,type:time,nullzero," json:"end_time"`
FamilyMemberID resolvespec_common.SqlUUID `bun:"family_member_id,type:uuid,nullzero," json:"family_member_id"`
Location resolvespec_common.SqlString `bun:"location,type:text,nullzero," json:"location"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
StartDate resolvespec_common.SqlDate `bun:"start_date,type:date,nullzero," json:"start_date"`
StartTime resolvespec_common.SqlTime `bun:"start_time,type:time,nullzero," json:"start_time"`
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
RelFamilyMemberID *ModelPublicFamilyMembers `bun:"rel:has-one,join:family_member_id=id" json:"relfamilymemberid,omitempty"` // Has one ModelPublicFamilyMembers
}
// TableName returns the table name for ModelPublicActivities
func (m ModelPublicActivities) TableName() string {
return "public.activities"
}
// TableNameOnly returns the table name without schema for ModelPublicActivities
func (m ModelPublicActivities) TableNameOnly() string {
return "activities"
}
// SchemaName returns the schema name for ModelPublicActivities
func (m ModelPublicActivities) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicActivities) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicActivities) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicActivities) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicActivities) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicActivities) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicActivities) GetPrefix() string {
return "ACT"
}

View File

@@ -17,6 +17,7 @@ type ModelPublicAgentGuardrails struct {
Severity resolvespec_common.SqlString `bun:"severity,type:text,default:'medium',notnull," json:"severity"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelGuardrailIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails
RelGuardrailIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
}

View File

@@ -17,6 +17,7 @@ type ModelPublicAgentSkills struct {
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelRelatedSkillIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_skill_id" json:"relrelatedskillidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
RelSkillIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills
RelSkillIDPublicProjectSkills []*ModelPublicProjectSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicprojectskills,omitempty"` // Has many ModelPublicProjectSkills
}

View File

@@ -1,66 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicContactInteractions struct {
bun.BaseModel `bun:"table:public.contact_interactions,alias:contact_interactions"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
ContactID resolvespec_common.SqlUUID `bun:"contact_id,type:uuid,notnull," json:"contact_id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
FollowUpNeeded bool `bun:"follow_up_needed,type:boolean,default:false,notnull," json:"follow_up_needed"`
FollowUpNotes resolvespec_common.SqlString `bun:"follow_up_notes,type:text,nullzero," json:"follow_up_notes"`
InteractionType resolvespec_common.SqlString `bun:"interaction_type,type:text,notnull," json:"interaction_type"`
OccurredAt resolvespec_common.SqlTimeStamp `bun:"occurred_at,type:timestamptz,default:now(),notnull," json:"occurred_at"`
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
RelContactID *ModelPublicProfessionalContacts `bun:"rel:has-one,join:contact_id=id" json:"relcontactid,omitempty"` // Has one ModelPublicProfessionalContacts
}
// TableName returns the table name for ModelPublicContactInteractions
func (m ModelPublicContactInteractions) TableName() string {
return "public.contact_interactions"
}
// TableNameOnly returns the table name without schema for ModelPublicContactInteractions
func (m ModelPublicContactInteractions) TableNameOnly() string {
return "contact_interactions"
}
// SchemaName returns the schema name for ModelPublicContactInteractions
func (m ModelPublicContactInteractions) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicContactInteractions) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicContactInteractions) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicContactInteractions) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicContactInteractions) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicContactInteractions) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicContactInteractions) GetPrefix() string {
return "CIO"
}

View File

@@ -1,65 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicFamilyMembers struct {
bun.BaseModel `bun:"table:public.family_members,alias:family_members"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
BirthDate resolvespec_common.SqlDate `bun:"birth_date,type:date,nullzero," json:"birth_date"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
Relationship resolvespec_common.SqlString `bun:"relationship,type:text,nullzero," json:"relationship"`
RelFamilyMemberIDPublicActivities []*ModelPublicActivities `bun:"rel:has-many,join:id=family_member_id" json:"relfamilymemberidpublicactivities,omitempty"` // Has many ModelPublicActivities
RelFamilyMemberIDPublicImportantDates []*ModelPublicImportantDates `bun:"rel:has-many,join:id=family_member_id" json:"relfamilymemberidpublicimportantdates,omitempty"` // Has many ModelPublicImportantDates
}
// TableName returns the table name for ModelPublicFamilyMembers
func (m ModelPublicFamilyMembers) TableName() string {
return "public.family_members"
}
// TableNameOnly returns the table name without schema for ModelPublicFamilyMembers
func (m ModelPublicFamilyMembers) TableNameOnly() string {
return "family_members"
}
// SchemaName returns the schema name for ModelPublicFamilyMembers
func (m ModelPublicFamilyMembers) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicFamilyMembers) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicFamilyMembers) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicFamilyMembers) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicFamilyMembers) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicFamilyMembers) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicFamilyMembers) GetPrefix() string {
return "FMA"
}

View File

@@ -1,65 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicHouseholdItems struct {
bun.BaseModel `bun:"table:public.household_items,alias:household_items"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
Category resolvespec_common.SqlString `bun:"category,type:text,nullzero," json:"category"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Details resolvespec_common.SqlJSONB `bun:"details,type:jsonb,default:'{}',notnull," json:"details"`
Location resolvespec_common.SqlString `bun:"location,type:text,nullzero," json:"location"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
}
// TableName returns the table name for ModelPublicHouseholdItems
func (m ModelPublicHouseholdItems) TableName() string {
return "public.household_items"
}
// TableNameOnly returns the table name without schema for ModelPublicHouseholdItems
func (m ModelPublicHouseholdItems) TableNameOnly() string {
return "household_items"
}
// SchemaName returns the schema name for ModelPublicHouseholdItems
func (m ModelPublicHouseholdItems) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicHouseholdItems) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicHouseholdItems) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicHouseholdItems) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicHouseholdItems) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicHouseholdItems) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicHouseholdItems) GetPrefix() string {
return "HIO"
}

View File

@@ -1,67 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicHouseholdVendors struct {
bun.BaseModel `bun:"table:public.household_vendors,alias:household_vendors"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Email resolvespec_common.SqlString `bun:"email,type:text,nullzero," json:"email"`
LastUsed resolvespec_common.SqlDate `bun:"last_used,type:date,nullzero," json:"last_used"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
Phone resolvespec_common.SqlString `bun:"phone,type:text,nullzero," json:"phone"`
Rating resolvespec_common.SqlInt32 `bun:"rating,type:int,nullzero," json:"rating"`
ServiceType resolvespec_common.SqlString `bun:"service_type,type:text,nullzero," json:"service_type"`
Website resolvespec_common.SqlString `bun:"website,type:text,nullzero," json:"website"`
}
// TableName returns the table name for ModelPublicHouseholdVendors
func (m ModelPublicHouseholdVendors) TableName() string {
return "public.household_vendors"
}
// TableNameOnly returns the table name without schema for ModelPublicHouseholdVendors
func (m ModelPublicHouseholdVendors) TableNameOnly() string {
return "household_vendors"
}
// SchemaName returns the schema name for ModelPublicHouseholdVendors
func (m ModelPublicHouseholdVendors) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicHouseholdVendors) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicHouseholdVendors) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicHouseholdVendors) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicHouseholdVendors) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicHouseholdVendors) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicHouseholdVendors) GetPrefix() string {
return "HVO"
}

View File

@@ -1,66 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicImportantDates struct {
bun.BaseModel `bun:"table:public.important_dates,alias:important_dates"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
DateValue resolvespec_common.SqlDate `bun:"date_value,type:date,notnull," json:"date_value"`
FamilyMemberID resolvespec_common.SqlUUID `bun:"family_member_id,type:uuid,nullzero," json:"family_member_id"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
RecurringYearly bool `bun:"recurring_yearly,type:boolean,default:false,notnull," json:"recurring_yearly"`
ReminderDaysBefore resolvespec_common.SqlInt32 `bun:"reminder_days_before,type:int,default:7,notnull," json:"reminder_days_before"`
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
RelFamilyMemberID *ModelPublicFamilyMembers `bun:"rel:has-one,join:family_member_id=id" json:"relfamilymemberid,omitempty"` // Has one ModelPublicFamilyMembers
}
// TableName returns the table name for ModelPublicImportantDates
func (m ModelPublicImportantDates) TableName() string {
return "public.important_dates"
}
// TableNameOnly returns the table name without schema for ModelPublicImportantDates
func (m ModelPublicImportantDates) TableNameOnly() string {
return "important_dates"
}
// SchemaName returns the schema name for ModelPublicImportantDates
func (m ModelPublicImportantDates) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicImportantDates) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicImportantDates) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicImportantDates) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicImportantDates) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicImportantDates) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicImportantDates) GetPrefix() string {
return "IDM"
}

View File

@@ -1,65 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicMaintenanceLogs struct {
bun.BaseModel `bun:"table:public.maintenance_logs,alias:maintenance_logs"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
CompletedAt resolvespec_common.SqlTimeStamp `bun:"completed_at,type:timestamptz,default:now(),notnull," json:"completed_at"`
Cost resolvespec_common.SqlFloat64 `bun:"cost,type:decimal(10,2),nullzero," json:"cost"`
NextAction resolvespec_common.SqlString `bun:"next_action,type:text,nullzero," json:"next_action"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
PerformedBy resolvespec_common.SqlString `bun:"performed_by,type:text,nullzero," json:"performed_by"`
TaskID resolvespec_common.SqlUUID `bun:"task_id,type:uuid,notnull," json:"task_id"`
RelTaskID *ModelPublicMaintenanceTasks `bun:"rel:has-one,join:task_id=id" json:"reltaskid,omitempty"` // Has one ModelPublicMaintenanceTasks
}
// TableName returns the table name for ModelPublicMaintenanceLogs
func (m ModelPublicMaintenanceLogs) TableName() string {
return "public.maintenance_logs"
}
// TableNameOnly returns the table name without schema for ModelPublicMaintenanceLogs
func (m ModelPublicMaintenanceLogs) TableNameOnly() string {
return "maintenance_logs"
}
// SchemaName returns the schema name for ModelPublicMaintenanceLogs
func (m ModelPublicMaintenanceLogs) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicMaintenanceLogs) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicMaintenanceLogs) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicMaintenanceLogs) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicMaintenanceLogs) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicMaintenanceLogs) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicMaintenanceLogs) GetPrefix() string {
return "MLA"
}

View File

@@ -1,68 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicMaintenanceTasks struct {
bun.BaseModel `bun:"table:public.maintenance_tasks,alias:maintenance_tasks"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
Category resolvespec_common.SqlString `bun:"category,type:text,nullzero," json:"category"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
FrequencyDays resolvespec_common.SqlInt32 `bun:"frequency_days,type:int,nullzero," json:"frequency_days"`
LastCompleted resolvespec_common.SqlTimeStamp `bun:"last_completed,type:timestamptz,nullzero," json:"last_completed"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
NextDue resolvespec_common.SqlTimeStamp `bun:"next_due,type:timestamptz,nullzero," json:"next_due"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelTaskIDPublicMaintenanceLogs []*ModelPublicMaintenanceLogs `bun:"rel:has-many,join:id=task_id" json:"reltaskidpublicmaintenancelogs,omitempty"` // Has many ModelPublicMaintenanceLogs
}
// TableName returns the table name for ModelPublicMaintenanceTasks
func (m ModelPublicMaintenanceTasks) TableName() string {
return "public.maintenance_tasks"
}
// TableNameOnly returns the table name without schema for ModelPublicMaintenanceTasks
func (m ModelPublicMaintenanceTasks) TableNameOnly() string {
return "maintenance_tasks"
}
// SchemaName returns the schema name for ModelPublicMaintenanceTasks
func (m ModelPublicMaintenanceTasks) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicMaintenanceTasks) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicMaintenanceTasks) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicMaintenanceTasks) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicMaintenanceTasks) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicMaintenanceTasks) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicMaintenanceTasks) GetPrefix() string {
return "MTA"
}

View File

@@ -1,67 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicMealPlans struct {
bun.BaseModel `bun:"table:public.meal_plans,alias:meal_plans"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
CustomMeal resolvespec_common.SqlString `bun:"custom_meal,type:text,nullzero," json:"custom_meal"`
DayOfWeek resolvespec_common.SqlString `bun:"day_of_week,type:text,notnull," json:"day_of_week"`
MealType resolvespec_common.SqlString `bun:"meal_type,type:text,notnull," json:"meal_type"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
RecipeID resolvespec_common.SqlUUID `bun:"recipe_id,type:uuid,nullzero," json:"recipe_id"`
Servings resolvespec_common.SqlInt32 `bun:"servings,type:int,nullzero," json:"servings"`
WeekStart resolvespec_common.SqlDate `bun:"week_start,type:date,notnull," json:"week_start"`
RelRecipeID *ModelPublicRecipes `bun:"rel:has-one,join:recipe_id=id" json:"relrecipeid,omitempty"` // Has one ModelPublicRecipes
}
// TableName returns the table name for ModelPublicMealPlans
func (m ModelPublicMealPlans) TableName() string {
return "public.meal_plans"
}
// TableNameOnly returns the table name without schema for ModelPublicMealPlans
func (m ModelPublicMealPlans) TableNameOnly() string {
return "meal_plans"
}
// SchemaName returns the schema name for ModelPublicMealPlans
func (m ModelPublicMealPlans) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicMealPlans) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicMealPlans) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicMealPlans) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicMealPlans) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicMealPlans) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicMealPlans) GetPrefix() string {
return "MPE"
}

View File

@@ -1,68 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicOpportunities struct {
bun.BaseModel `bun:"table:public.opportunities,alias:opportunities"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
ContactID resolvespec_common.SqlUUID `bun:"contact_id,type:uuid,nullzero," json:"contact_id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,nullzero," json:"description"`
ExpectedCloseDate resolvespec_common.SqlDate `bun:"expected_close_date,type:date,nullzero," json:"expected_close_date"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
Stage resolvespec_common.SqlString `bun:"stage,type:text,default:'identified',notnull," json:"stage"`
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
Value resolvespec_common.SqlFloat64 `bun:"value,type:decimal(12,2),nullzero," json:"value"`
RelContactID *ModelPublicProfessionalContacts `bun:"rel:has-one,join:contact_id=id" json:"relcontactid,omitempty"` // Has one ModelPublicProfessionalContacts
}
// TableName returns the table name for ModelPublicOpportunities
func (m ModelPublicOpportunities) TableName() string {
return "public.opportunities"
}
// TableNameOnly returns the table name without schema for ModelPublicOpportunities
func (m ModelPublicOpportunities) TableNameOnly() string {
return "opportunities"
}
// SchemaName returns the schema name for ModelPublicOpportunities
func (m ModelPublicOpportunities) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicOpportunities) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicOpportunities) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicOpportunities) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicOpportunities) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicOpportunities) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicOpportunities) GetPrefix() string {
return "OPP"
}

View File

@@ -0,0 +1,63 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicPlanDependencies struct {
bun.BaseModel `bun:"table:public.plan_dependencies,alias:plan_dependencies"`
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
DependsOnPlanID resolvespec_common.SqlUUID `bun:"depends_on_plan_id,type:uuid,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"depends_on_plan_id"`
PlanID resolvespec_common.SqlUUID `bun:"plan_id,type:uuid,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"plan_id"`
RelDependsOnPlanID *ModelPublicPlans `bun:"rel:has-one,join:depends_on_plan_id=id" json:"reldependsonplanid,omitempty"` // Has one ModelPublicPlans
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
}
// TableName returns the table name for ModelPublicPlanDependencies
func (m ModelPublicPlanDependencies) TableName() string {
return "public.plan_dependencies"
}
// TableNameOnly returns the table name without schema for ModelPublicPlanDependencies
func (m ModelPublicPlanDependencies) TableNameOnly() string {
return "plan_dependencies"
}
// SchemaName returns the schema name for ModelPublicPlanDependencies
func (m ModelPublicPlanDependencies) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicPlanDependencies) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicPlanDependencies) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicPlanDependencies) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicPlanDependencies) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicPlanDependencies) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicPlanDependencies) GetPrefix() string {
return "PDL"
}

View File

@@ -0,0 +1,63 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicPlanGuardrails struct {
bun.BaseModel `bun:"table:public.plan_guardrails,alias:plan_guardrails"`
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
GuardrailID resolvespec_common.SqlUUID `bun:"guardrail_id,type:uuid,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"guardrail_id"`
PlanID resolvespec_common.SqlUUID `bun:"plan_id,type:uuid,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"plan_id"`
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
}
// TableName returns the table name for ModelPublicPlanGuardrails
func (m ModelPublicPlanGuardrails) TableName() string {
return "public.plan_guardrails"
}
// TableNameOnly returns the table name without schema for ModelPublicPlanGuardrails
func (m ModelPublicPlanGuardrails) TableNameOnly() string {
return "plan_guardrails"
}
// SchemaName returns the schema name for ModelPublicPlanGuardrails
func (m ModelPublicPlanGuardrails) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicPlanGuardrails) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicPlanGuardrails) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicPlanGuardrails) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicPlanGuardrails) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicPlanGuardrails) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicPlanGuardrails) GetPrefix() string {
return "PGL"
}

View File

@@ -0,0 +1,63 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicPlanRelatedPlans struct {
bun.BaseModel `bun:"table:public.plan_related_plans,alias:plan_related_plans"`
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
PlanAID resolvespec_common.SqlUUID `bun:"plan_a_id,type:uuid,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_a_id"`
PlanBID resolvespec_common.SqlUUID `bun:"plan_b_id,type:uuid,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_b_id"`
RelPlanAID *ModelPublicPlans `bun:"rel:has-one,join:plan_a_id=id" json:"relplanaid,omitempty"` // Has one ModelPublicPlans
RelPlanBID *ModelPublicPlans `bun:"rel:has-one,join:plan_b_id=id" json:"relplanbid,omitempty"` // Has one ModelPublicPlans
}
// TableName returns the table name for ModelPublicPlanRelatedPlans
func (m ModelPublicPlanRelatedPlans) TableName() string {
return "public.plan_related_plans"
}
// TableNameOnly returns the table name without schema for ModelPublicPlanRelatedPlans
func (m ModelPublicPlanRelatedPlans) TableNameOnly() string {
return "plan_related_plans"
}
// SchemaName returns the schema name for ModelPublicPlanRelatedPlans
func (m ModelPublicPlanRelatedPlans) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicPlanRelatedPlans) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicPlanRelatedPlans) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicPlanRelatedPlans) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicPlanRelatedPlans) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicPlanRelatedPlans) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicPlanRelatedPlans) GetPrefix() string {
return "PRP"
}

View File

@@ -0,0 +1,63 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicPlanSkills struct {
bun.BaseModel `bun:"table:public.plan_skills,alias:plan_skills"`
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
PlanID resolvespec_common.SqlUUID `bun:"plan_id,type:uuid,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"plan_id"`
SkillID resolvespec_common.SqlUUID `bun:"skill_id,type:uuid,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"skill_id"`
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
RelSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:skill_id=id" json:"relskillid,omitempty"` // Has one ModelPublicAgentSkills
}
// TableName returns the table name for ModelPublicPlanSkills
func (m ModelPublicPlanSkills) TableName() string {
return "public.plan_skills"
}
// TableNameOnly returns the table name without schema for ModelPublicPlanSkills
func (m ModelPublicPlanSkills) TableNameOnly() string {
return "plan_skills"
}
// SchemaName returns the schema name for ModelPublicPlanSkills
func (m ModelPublicPlanSkills) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicPlanSkills) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicPlanSkills) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicPlanSkills) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicPlanSkills) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicPlanSkills) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicPlanSkills) GetPrefix() string {
return "PSL"
}

View File

@@ -0,0 +1,80 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicPlans struct {
bun.BaseModel `bun:"table:public.plans,alias:plans"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
CompletedAt resolvespec_common.SqlTimeStamp `bun:"completed_at,type:timestamptz,nullzero," json:"completed_at"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
DueDate resolvespec_common.SqlTimeStamp `bun:"due_date,type:timestamptz,nullzero," json:"due_date"`
LastReviewedAt resolvespec_common.SqlTimeStamp `bun:"last_reviewed_at,type:timestamptz,nullzero," json:"last_reviewed_at"`
Owner resolvespec_common.SqlString `bun:"owner,type:text,nullzero," json:"owner"`
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"` // low, medium, high, critical
ProjectID resolvespec_common.SqlUUID `bun:"project_id,type:uuid,nullzero," json:"project_id"`
ReviewedBy resolvespec_common.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
Status resolvespec_common.SqlString `bun:"status,type:text,default:'draft',notnull," json:"status"` // draft, active, blocked, completed, cancelled, superseded
SupersedesPlanID resolvespec_common.SqlUUID `bun:"supersedes_plan_id,type:uuid,nullzero," json:"supersedes_plan_id"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=guid" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
RelSupersedesPlanID *ModelPublicPlans `bun:"rel:has-one,join:supersedes_plan_id=id" json:"relsupersedesplanid,omitempty"` // Has one ModelPublicPlans
RelDependsOnPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=depends_on_plan_id" json:"reldependsonplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
RelPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
RelPlanAIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_a_id" json:"relplanaidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans
RelPlanBIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_b_id" json:"relplanbidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans
RelPlanIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills
RelPlanIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails
}
// TableName returns the table name for ModelPublicPlans
func (m ModelPublicPlans) TableName() string {
return "public.plans"
}
// TableNameOnly returns the table name without schema for ModelPublicPlans
func (m ModelPublicPlans) TableNameOnly() string {
return "plans"
}
// SchemaName returns the schema name for ModelPublicPlans
func (m ModelPublicPlans) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicPlans) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicPlans) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicPlans) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicPlans) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicPlans) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicPlans) GetPrefix() string {
return "PLA"
}

View File

@@ -1,73 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicProfessionalContacts struct {
bun.BaseModel `bun:"table:public.professional_contacts,alias:professional_contacts"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
Company resolvespec_common.SqlString `bun:"company,type:text,nullzero," json:"company"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Email resolvespec_common.SqlString `bun:"email,type:text,nullzero," json:"email"`
FollowUpDate resolvespec_common.SqlDate `bun:"follow_up_date,type:date,nullzero," json:"follow_up_date"`
HowWeMet resolvespec_common.SqlString `bun:"how_we_met,type:text,nullzero," json:"how_we_met"`
LastContacted resolvespec_common.SqlTimeStamp `bun:"last_contacted,type:timestamptz,nullzero," json:"last_contacted"`
LinkedinURL resolvespec_common.SqlString `bun:"linkedin_url,type:text,nullzero," json:"linkedin_url"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
Phone resolvespec_common.SqlString `bun:"phone,type:text,nullzero," json:"phone"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
Title resolvespec_common.SqlString `bun:"title,type:text,nullzero," json:"title"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelContactIDPublicContactInteractions []*ModelPublicContactInteractions `bun:"rel:has-many,join:id=contact_id" json:"relcontactidpubliccontactinteractions,omitempty"` // Has many ModelPublicContactInteractions
RelContactIDPublicOpportunities []*ModelPublicOpportunities `bun:"rel:has-many,join:id=contact_id" json:"relcontactidpublicopportunities,omitempty"` // Has many ModelPublicOpportunities
}
// TableName returns the table name for ModelPublicProfessionalContacts
func (m ModelPublicProfessionalContacts) TableName() string {
return "public.professional_contacts"
}
// TableNameOnly returns the table name without schema for ModelPublicProfessionalContacts
func (m ModelPublicProfessionalContacts) TableNameOnly() string {
return "professional_contacts"
}
// SchemaName returns the schema name for ModelPublicProfessionalContacts
func (m ModelPublicProfessionalContacts) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicProfessionalContacts) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicProfessionalContacts) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicProfessionalContacts) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicProfessionalContacts) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicProfessionalContacts) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicProfessionalContacts) GetPrefix() string {
return "PCR"
}

View File

@@ -20,6 +20,7 @@ type ModelPublicProjects struct {
RelProjectIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
RelProjectIDPublicChatHistories []*ModelPublicChatHistories `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicchathistories,omitempty"` // Has many ModelPublicChatHistories
RelProjectIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
RelProjectIDPublicPlans []*ModelPublicPlans `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicplans,omitempty"` // Has many ModelPublicPlans
RelProjectIDPublicProjectSkills []*ModelPublicProjectSkills `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicprojectskills,omitempty"` // Has many ModelPublicProjectSkills
RelProjectIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
}

View File

@@ -1,71 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicRecipes struct {
bun.BaseModel `bun:"table:public.recipes,alias:recipes"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
CookTimeMinutes resolvespec_common.SqlInt32 `bun:"cook_time_minutes,type:int,nullzero," json:"cook_time_minutes"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Cuisine resolvespec_common.SqlString `bun:"cuisine,type:text,nullzero," json:"cuisine"`
Ingredients resolvespec_common.SqlJSONB `bun:"ingredients,type:jsonb,default:'[',notnull," json:"ingredients"`
Instructions resolvespec_common.SqlJSONB `bun:"instructions,type:jsonb,default:'[',notnull," json:"instructions"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
PrepTimeMinutes resolvespec_common.SqlInt32 `bun:"prep_time_minutes,type:int,nullzero," json:"prep_time_minutes"`
Rating resolvespec_common.SqlInt32 `bun:"rating,type:int,nullzero," json:"rating"`
Servings resolvespec_common.SqlInt32 `bun:"servings,type:int,nullzero," json:"servings"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelRecipeIDPublicMealPlans []*ModelPublicMealPlans `bun:"rel:has-many,join:id=recipe_id" json:"relrecipeidpublicmealplans,omitempty"` // Has many ModelPublicMealPlans
}
// TableName returns the table name for ModelPublicRecipes
func (m ModelPublicRecipes) TableName() string {
return "public.recipes"
}
// TableNameOnly returns the table name without schema for ModelPublicRecipes
func (m ModelPublicRecipes) TableNameOnly() string {
return "recipes"
}
// SchemaName returns the schema name for ModelPublicRecipes
func (m ModelPublicRecipes) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicRecipes) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicRecipes) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicRecipes) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicRecipes) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicRecipes) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicRecipes) GetPrefix() string {
return "REC"
}

View File

@@ -1,63 +0,0 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicShoppingLists struct {
bun.BaseModel `bun:"table:public.shopping_lists,alias:shopping_lists"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Items resolvespec_common.SqlJSONB `bun:"items,type:jsonb,default:'[',notnull," json:"items"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
WeekStart resolvespec_common.SqlDate `bun:"week_start,type:date,notnull," json:"week_start"`
}
// TableName returns the table name for ModelPublicShoppingLists
func (m ModelPublicShoppingLists) TableName() string {
return "public.shopping_lists"
}
// TableNameOnly returns the table name without schema for ModelPublicShoppingLists
func (m ModelPublicShoppingLists) TableNameOnly() string {
return "shopping_lists"
}
// SchemaName returns the schema name for ModelPublicShoppingLists
func (m ModelPublicShoppingLists) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicShoppingLists) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicShoppingLists) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicShoppingLists) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicShoppingLists) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicShoppingLists) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicShoppingLists) GetPrefix() string {
return "SLH"
}

View File

@@ -36,11 +36,12 @@ type ToolSet struct {
Backfill *tools.BackfillTool
Reparse *tools.ReparseMetadataTool
RetryMetadata *tools.RetryEnrichmentTool
Maintenance *tools.MaintenanceTool
Skills *tools.SkillsTool
ChatHistory *tools.ChatHistoryTool
Describe *tools.DescribeTool
Learnings *tools.LearningsTool
//Maintenance *tools.MaintenanceTool
Skills *tools.SkillsTool
ChatHistory *tools.ChatHistoryTool
Describe *tools.DescribeTool
Learnings *tools.LearningsTool
Plans *tools.PlansTool
}
// Handlers groups the HTTP handlers produced for an MCP server instance.
@@ -85,6 +86,7 @@ func NewHandlers(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onS
registerThoughtTools,
registerProjectTools,
registerLearningTools,
registerPlanTools,
registerFileTools,
registerMaintenanceTools,
registerSkillTools,
@@ -273,6 +275,100 @@ func registerLearningTools(server *mcp.Server, logger *slog.Logger, toolSet Tool
return nil
}
func registerPlanTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
if err := addTool(server, logger, &mcp.Tool{
Name: "create_plan",
Description: "Create a structured plan linked to a project.",
}, toolSet.Plans.Create); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "get_plan",
Description: "Retrieve a plan with its dependencies, related plans, skills, and guardrails.",
}, toolSet.Plans.Get); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "update_plan",
Description: "Update plan fields; only provided fields are changed.",
}, toolSet.Plans.Update); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "delete_plan",
Description: "Hard-delete a plan by id.",
}, toolSet.Plans.Delete); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "list_plans",
Description: "List plans with optional project, status, priority, owner, tag, and text filters.",
}, toolSet.Plans.List); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "add_plan_dependency",
Description: "Mark plan_id as depending on depends_on_plan_id (must complete first).",
}, toolSet.Plans.AddDependency); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "remove_plan_dependency",
Description: "Remove a dependency between two plans.",
}, toolSet.Plans.RemoveDependency); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "add_related_plan",
Description: "Link two plans as thematically related (bidirectional).",
}, toolSet.Plans.AddRelated); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "remove_related_plan",
Description: "Unlink two related plans.",
}, toolSet.Plans.RemoveRelated); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "add_plan_skill",
Description: "Link an agent skill to a plan.",
}, toolSet.Plans.AddSkill); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "remove_plan_skill",
Description: "Unlink an agent skill from a plan.",
}, toolSet.Plans.RemoveSkill); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "list_plan_skills",
Description: "List skills linked to a plan.",
}, toolSet.Plans.ListSkills); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "add_plan_guardrail",
Description: "Link an agent guardrail to a plan.",
}, toolSet.Plans.AddGuardrail); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "remove_plan_guardrail",
Description: "Unlink an agent guardrail from a plan.",
}, toolSet.Plans.RemoveGuardrail); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "list_plan_guardrails",
Description: "List guardrails linked to a plan.",
}, toolSet.Plans.ListGuardrails); err != nil {
return err
}
return nil
}
func registerFileTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
server.AddResourceTemplate(&mcp.ResourceTemplate{
Name: "stored_file",
@@ -326,30 +422,30 @@ func registerMaintenanceTools(server *mcp.Server, logger *slog.Logger, toolSet T
}, toolSet.RetryMetadata.Handle); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "add_maintenance_task",
Description: "Create a recurring or one-time home maintenance task.",
}, toolSet.Maintenance.AddTask); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "log_maintenance",
Description: "Log completed maintenance; updates next due date.",
}, toolSet.Maintenance.LogWork); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "get_upcoming_maintenance",
Description: "List maintenance tasks due within the next N days.",
}, toolSet.Maintenance.GetUpcoming); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "search_maintenance_history",
Description: "Search the maintenance log by task name, category, or date range.",
}, toolSet.Maintenance.SearchHistory); err != nil {
return err
}
// if err := addTool(server, logger, &mcp.Tool{
// Name: "add_maintenance_task",
// Description: "Create a recurring or one-time home maintenance task.",
// }, toolSet.Maintenance.AddTask); err != nil {
// return err
// }
// if err := addTool(server, logger, &mcp.Tool{
// Name: "log_maintenance",
// Description: "Log completed maintenance; updates next due date.",
// }, toolSet.Maintenance.LogWork); err != nil {
// return err
// }
// if err := addTool(server, logger, &mcp.Tool{
// Name: "get_upcoming_maintenance",
// Description: "List maintenance tasks due within the next N days.",
// }, toolSet.Maintenance.GetUpcoming); err != nil {
// return err
// }
// if err := addTool(server, logger, &mcp.Tool{
// Name: "search_maintenance_history",
// Description: "Search the maintenance log by task name, category, or date range.",
// }, toolSet.Maintenance.SearchHistory); err != nil {
// return err
// }
return nil
}
@@ -460,7 +556,7 @@ func registerChatHistoryTools(server *mcp.Server, logger *slog.Logger, toolSet T
func registerDescribeTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
if err := addTool(server, logger, &mcp.Tool{
Name: "describe_tools",
Description: "Call first each session. All tools with categories and usage notes. Categories: system, thoughts, projects, files, admin, maintenance, skills, chat, meta.",
Description: "Call first each session. All tools with categories and usage notes. Categories: system, thoughts, projects, files, admin, maintenance, skills, plans, chat, meta.",
}, toolSet.Describe.Describe); err != nil {
return err
}
@@ -506,6 +602,23 @@ func BuildToolCatalog() []tools.ToolEntry {
{Name: "get_learning", Description: "Retrieve a structured learning by id.", Category: "projects"},
{Name: "list_learnings", Description: "List structured learnings with optional project, category, area, status, priority, tag, and text filters.", Category: "projects"},
// plans
{Name: "create_plan", Description: "Create a structured plan with status, priority, owner, due date, and optional project link.", Category: "plans"},
{Name: "get_plan", Description: "Retrieve a full plan including dependencies (depends_on/blocks), related plans, linked skills, and guardrails.", Category: "plans"},
{Name: "update_plan", Description: "Partially update a plan; only provided fields are changed. Use mark_reviewed to stamp last_reviewed_at.", Category: "plans"},
{Name: "delete_plan", Description: "Hard-delete a plan by id.", Category: "plans"},
{Name: "list_plans", Description: "List plans with optional filters: project, status, priority, owner, tag, and full-text query.", Category: "plans"},
{Name: "add_plan_dependency", Description: "Declare that plan_id cannot proceed until depends_on_plan_id is complete.", Category: "plans"},
{Name: "remove_plan_dependency", Description: "Remove a directional dependency between two plans.", Category: "plans"},
{Name: "add_related_plan", Description: "Link two plans as thematically related (bidirectional, order-independent).", Category: "plans"},
{Name: "remove_related_plan", Description: "Unlink two related plans.", Category: "plans"},
{Name: "add_plan_skill", Description: "Link an agent skill to a plan so it is loaded with the plan's context.", Category: "plans"},
{Name: "remove_plan_skill", Description: "Unlink an agent skill from a plan.", Category: "plans"},
{Name: "list_plan_skills", Description: "List all skills linked to a plan.", Category: "plans"},
{Name: "add_plan_guardrail", Description: "Link an agent guardrail to a plan so it applies during plan execution.", Category: "plans"},
{Name: "remove_plan_guardrail", Description: "Unlink an agent guardrail from a plan.", Category: "plans"},
{Name: "list_plan_guardrails", Description: "List all guardrails linked to a plan.", Category: "plans"},
// files
{Name: "upload_file", Description: "Stage a file and get an amcs://files/{id} resource URI. Use content_path (absolute server-side path, no size limit) for large or binary files, or content_base64 (≤10 MB) for small files. Pass thought_id/project to link immediately, or omit and pass the URI to save_file later.", Category: "files"},
{Name: "save_file", Description: "Store a file and optionally link it to a thought. Use content_base64 (≤10 MB) for small files, or content_uri (amcs://files/{id} from a prior upload_file) for previously staged files. For files larger than 10 MB, use upload_file with content_path first. If the goal is to retain the artifact, store the file directly instead of reading or summarising it first.", Category: "files"},
@@ -544,7 +657,7 @@ func BuildToolCatalog() []tools.ToolEntry {
{Name: "delete_chat_history", Description: "Permanently delete a saved chat history by id.", Category: "chat"},
// meta
{Name: "describe_tools", Description: "Call this first in every session. Returns all available MCP tools with names, descriptions, categories, and your accumulated usage notes. Filter by category to narrow results. Available categories: system, thoughts, projects, files, admin, household, maintenance, calendar, meals, crm, skills, chat, meta.", Category: "meta"},
{Name: "describe_tools", Description: "Call this first in every session. Returns all available MCP tools with names, descriptions, categories, and your accumulated usage notes. Filter by category to narrow results. Available categories: system, thoughts, projects, files, admin, household, maintenance, calendar, meals, crm, skills, plans, chat, meta.", Category: "meta"},
{Name: "annotate_tool", Description: "Persist usage notes, gotchas, or workflow patterns for a specific tool. Notes survive across sessions and are returned by describe_tools. Call this whenever you discover something non-obvious about a tool's behaviour. Pass an empty string to clear notes.", Category: "meta"},
}
}

View File

@@ -207,7 +207,7 @@ func streamableTestToolSet() ToolSet {
Backfill: new(tools.BackfillTool),
Reparse: new(tools.ReparseMetadataTool),
RetryMetadata: new(tools.RetryEnrichmentTool),
Maintenance: new(tools.MaintenanceTool),
Skills: new(tools.SkillsTool),
//Maintenance: new(tools.MaintenanceTool),
Skills: new(tools.SkillsTool),
}
}

View File

@@ -1,206 +1,206 @@
package store
import (
"context"
"fmt"
"strings"
"time"
// import (
// "context"
// "fmt"
// "strings"
// "time"
"github.com/google/uuid"
// "github.com/google/uuid"
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
// "git.warky.dev/wdevs/amcs/internal/generatedmodels"
// ext "git.warky.dev/wdevs/amcs/internal/types"
// )
func (db *DB) AddFamilyMember(ctx context.Context, m ext.FamilyMember) (ext.FamilyMember, error) {
row := db.pool.QueryRow(ctx, `
insert into family_members (name, relationship, birth_date, notes)
values ($1, $2, $3, $4)
returning id, created_at
`, m.Name, nullStr(m.Relationship), m.BirthDate, nullStr(m.Notes))
// func (db *DB) AddFamilyMember(ctx context.Context, m ext.FamilyMember) (ext.FamilyMember, error) {
// row := db.pool.QueryRow(ctx, `
// insert into family_members (name, relationship, birth_date, notes)
// values ($1, $2, $3, $4)
// returning id, created_at
// `, m.Name, nullStr(m.Relationship), m.BirthDate, nullStr(m.Notes))
created := m
var model generatedmodels.ModelPublicFamilyMembers
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
return ext.FamilyMember{}, fmt.Errorf("insert family member: %w", err)
}
created.ID = model.ID.UUID()
created.CreatedAt = model.CreatedAt.Time()
return created, nil
}
// created := m
// var model generatedmodels.ModelPublicFamilyMembers
// if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
// return ext.FamilyMember{}, fmt.Errorf("insert family member: %w", err)
// }
// created.ID = model.ID.UUID()
// created.CreatedAt = model.CreatedAt.Time()
// return created, nil
// }
func (db *DB) ListFamilyMembers(ctx context.Context) ([]ext.FamilyMember, error) {
rows, err := db.pool.Query(ctx, `select id, name, relationship, birth_date, notes, created_at from family_members order by name`)
if err != nil {
return nil, fmt.Errorf("list family members: %w", err)
}
defer rows.Close()
// func (db *DB) ListFamilyMembers(ctx context.Context) ([]ext.FamilyMember, error) {
// rows, err := db.pool.Query(ctx, `select id, name, relationship, birth_date, notes, created_at from family_members order by name`)
// if err != nil {
// return nil, fmt.Errorf("list family members: %w", err)
// }
// defer rows.Close()
var members []ext.FamilyMember
for rows.Next() {
var model generatedmodels.ModelPublicFamilyMembers
if err := rows.Scan(&model.ID, &model.Name, &model.Relationship, &model.BirthDate, &model.Notes, &model.CreatedAt); err != nil {
return nil, fmt.Errorf("scan family member: %w", err)
}
members = append(members, familyMemberFromModel(model))
}
return members, rows.Err()
}
// var members []ext.FamilyMember
// for rows.Next() {
// var model generatedmodels.ModelPublicFamilyMembers
// if err := rows.Scan(&model.ID, &model.Name, &model.Relationship, &model.BirthDate, &model.Notes, &model.CreatedAt); err != nil {
// return nil, fmt.Errorf("scan family member: %w", err)
// }
// members = append(members, familyMemberFromModel(model))
// }
// return members, rows.Err()
// }
func (db *DB) AddActivity(ctx context.Context, a ext.Activity) (ext.Activity, error) {
row := db.pool.QueryRow(ctx, `
insert into activities (family_member_id, title, activity_type, day_of_week, start_time, end_time, start_date, end_date, location, notes)
values ($1, $2, $3, $4, $5::time, $6::time, $7, $8, $9, $10)
returning id, created_at
`, a.FamilyMemberID, a.Title, nullStr(a.ActivityType), nullStr(a.DayOfWeek),
nullStr(a.StartTime), nullStr(a.EndTime), a.StartDate, a.EndDate,
nullStr(a.Location), nullStr(a.Notes))
// func (db *DB) AddActivity(ctx context.Context, a ext.Activity) (ext.Activity, error) {
// row := db.pool.QueryRow(ctx, `
// insert into activities (family_member_id, title, activity_type, day_of_week, start_time, end_time, start_date, end_date, location, notes)
// values ($1, $2, $3, $4, $5::time, $6::time, $7, $8, $9, $10)
// returning id, created_at
// `, a.FamilyMemberID, a.Title, nullStr(a.ActivityType), nullStr(a.DayOfWeek),
// nullStr(a.StartTime), nullStr(a.EndTime), a.StartDate, a.EndDate,
// nullStr(a.Location), nullStr(a.Notes))
created := a
var model generatedmodels.ModelPublicActivities
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
return ext.Activity{}, fmt.Errorf("insert activity: %w", err)
}
created.ID = model.ID.UUID()
created.CreatedAt = model.CreatedAt.Time()
return created, nil
}
// created := a
// var model generatedmodels.ModelPublicActivities
// if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
// return ext.Activity{}, fmt.Errorf("insert activity: %w", err)
// }
// created.ID = model.ID.UUID()
// created.CreatedAt = model.CreatedAt.Time()
// return created, nil
// }
func (db *DB) GetWeekSchedule(ctx context.Context, weekStart time.Time) ([]ext.Activity, error) {
weekEnd := weekStart.AddDate(0, 0, 7)
// func (db *DB) GetWeekSchedule(ctx context.Context, weekStart time.Time) ([]ext.Activity, error) {
// weekEnd := weekStart.AddDate(0, 0, 7)
rows, err := db.pool.Query(ctx, `
select a.id, a.family_member_id, fm.name, a.title, a.activity_type,
a.day_of_week, a.start_time::text, a.end_time::text,
a.start_date, a.end_date, a.location, a.notes, a.created_at
from activities a
left join family_members fm on fm.id = a.family_member_id
where (a.start_date >= $1 and a.start_date < $2)
or (a.day_of_week is not null and (a.end_date is null or a.end_date >= $1))
order by a.start_date, a.start_time
`, weekStart, weekEnd)
if err != nil {
return nil, fmt.Errorf("get week schedule: %w", err)
}
defer rows.Close()
// rows, err := db.pool.Query(ctx, `
// select a.id, a.family_member_id, fm.name, a.title, a.activity_type,
// a.day_of_week, a.start_time::text, a.end_time::text,
// a.start_date, a.end_date, a.location, a.notes, a.created_at
// from activities a
// left join family_members fm on fm.id = a.family_member_id
// where (a.start_date >= $1 and a.start_date < $2)
// or (a.day_of_week is not null and (a.end_date is null or a.end_date >= $1))
// order by a.start_date, a.start_time
// `, weekStart, weekEnd)
// if err != nil {
// return nil, fmt.Errorf("get week schedule: %w", err)
// }
// defer rows.Close()
return scanActivities(rows)
}
// return scanActivities(rows)
// }
func (db *DB) SearchActivities(ctx context.Context, query, activityType string, memberID *uuid.UUID) ([]ext.Activity, error) {
args := []any{}
conditions := []string{}
// func (db *DB) SearchActivities(ctx context.Context, query, activityType string, memberID *uuid.UUID) ([]ext.Activity, error) {
// args := []any{}
// conditions := []string{}
if q := strings.TrimSpace(query); q != "" {
args = append(args, "%"+q+"%")
conditions = append(conditions, fmt.Sprintf("(a.title ILIKE $%d OR a.notes ILIKE $%d)", len(args), len(args)))
}
if t := strings.TrimSpace(activityType); t != "" {
args = append(args, t)
conditions = append(conditions, fmt.Sprintf("a.activity_type = $%d", len(args)))
}
if memberID != nil {
args = append(args, *memberID)
conditions = append(conditions, fmt.Sprintf("a.family_member_id = $%d", len(args)))
}
// if q := strings.TrimSpace(query); q != "" {
// args = append(args, "%"+q+"%")
// conditions = append(conditions, fmt.Sprintf("(a.title ILIKE $%d OR a.notes ILIKE $%d)", len(args), len(args)))
// }
// if t := strings.TrimSpace(activityType); t != "" {
// args = append(args, t)
// conditions = append(conditions, fmt.Sprintf("a.activity_type = $%d", len(args)))
// }
// if memberID != nil {
// args = append(args, *memberID)
// conditions = append(conditions, fmt.Sprintf("a.family_member_id = $%d", len(args)))
// }
q := `
select a.id, a.family_member_id, fm.name, a.title, a.activity_type,
a.day_of_week, a.start_time::text, a.end_time::text,
a.start_date, a.end_date, a.location, a.notes, a.created_at
from activities a
left join family_members fm on fm.id = a.family_member_id
`
if len(conditions) > 0 {
q += " where " + strings.Join(conditions, " and ")
}
q += " order by a.start_date, a.start_time"
// q := `
// select a.id, a.family_member_id, fm.name, a.title, a.activity_type,
// a.day_of_week, a.start_time::text, a.end_time::text,
// a.start_date, a.end_date, a.location, a.notes, a.created_at
// from activities a
// left join family_members fm on fm.id = a.family_member_id
// `
// if len(conditions) > 0 {
// q += " where " + strings.Join(conditions, " and ")
// }
// q += " order by a.start_date, a.start_time"
rows, err := db.pool.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("search activities: %w", err)
}
defer rows.Close()
// rows, err := db.pool.Query(ctx, q, args...)
// if err != nil {
// return nil, fmt.Errorf("search activities: %w", err)
// }
// defer rows.Close()
return scanActivities(rows)
}
// return scanActivities(rows)
// }
func (db *DB) AddImportantDate(ctx context.Context, d ext.ImportantDate) (ext.ImportantDate, error) {
row := db.pool.QueryRow(ctx, `
insert into important_dates (family_member_id, title, date_value, recurring_yearly, reminder_days_before, notes)
values ($1, $2, $3, $4, $5, $6)
returning id, created_at
`, d.FamilyMemberID, d.Title, d.DateValue, d.RecurringYearly, d.ReminderDaysBefore, nullStr(d.Notes))
// func (db *DB) AddImportantDate(ctx context.Context, d ext.ImportantDate) (ext.ImportantDate, error) {
// row := db.pool.QueryRow(ctx, `
// insert into important_dates (family_member_id, title, date_value, recurring_yearly, reminder_days_before, notes)
// values ($1, $2, $3, $4, $5, $6)
// returning id, created_at
// `, d.FamilyMemberID, d.Title, d.DateValue, d.RecurringYearly, d.ReminderDaysBefore, nullStr(d.Notes))
created := d
var model generatedmodels.ModelPublicImportantDates
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
return ext.ImportantDate{}, fmt.Errorf("insert important date: %w", err)
}
created.ID = model.ID.UUID()
created.CreatedAt = model.CreatedAt.Time()
return created, nil
}
// created := d
// var model generatedmodels.ModelPublicImportantDates
// if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
// return ext.ImportantDate{}, fmt.Errorf("insert important date: %w", err)
// }
// created.ID = model.ID.UUID()
// created.CreatedAt = model.CreatedAt.Time()
// return created, nil
// }
func (db *DB) GetUpcomingDates(ctx context.Context, daysAhead int) ([]ext.ImportantDate, error) {
if daysAhead <= 0 {
daysAhead = 30
}
now := time.Now()
cutoff := now.AddDate(0, 0, daysAhead)
// func (db *DB) GetUpcomingDates(ctx context.Context, daysAhead int) ([]ext.ImportantDate, error) {
// if daysAhead <= 0 {
// daysAhead = 30
// }
// now := time.Now()
// cutoff := now.AddDate(0, 0, daysAhead)
// For yearly recurring events, check if this year's occurrence falls in range
rows, err := db.pool.Query(ctx, `
select d.id, d.family_member_id, fm.name, d.title, d.date_value,
d.recurring_yearly, d.reminder_days_before, d.notes, d.created_at
from important_dates d
left join family_members fm on fm.id = d.family_member_id
where (
(d.recurring_yearly = false and d.date_value between $1 and $2)
or
(d.recurring_yearly = true and
make_date(extract(year from now())::int, extract(month from d.date_value)::int, extract(day from d.date_value)::int)
between $1 and $2)
)
order by d.date_value
`, now, cutoff)
if err != nil {
return nil, fmt.Errorf("get upcoming dates: %w", err)
}
defer rows.Close()
// // For yearly recurring events, check if this year's occurrence falls in range
// rows, err := db.pool.Query(ctx, `
// select d.id, d.family_member_id, fm.name, d.title, d.date_value,
// d.recurring_yearly, d.reminder_days_before, d.notes, d.created_at
// from important_dates d
// left join family_members fm on fm.id = d.family_member_id
// where (
// (d.recurring_yearly = false and d.date_value between $1 and $2)
// or
// (d.recurring_yearly = true and
// make_date(extract(year from now())::int, extract(month from d.date_value)::int, extract(day from d.date_value)::int)
// between $1 and $2)
// )
// order by d.date_value
// `, now, cutoff)
// if err != nil {
// return nil, fmt.Errorf("get upcoming dates: %w", err)
// }
// defer rows.Close()
var dates []ext.ImportantDate
for rows.Next() {
var model generatedmodels.ModelPublicImportantDates
var memberName *string
if err := rows.Scan(&model.ID, &model.FamilyMemberID, &memberName, &model.Title, &model.DateValue,
&model.RecurringYearly, &model.ReminderDaysBefore, &model.Notes, &model.CreatedAt); err != nil {
return nil, fmt.Errorf("scan important date: %w", err)
}
dates = append(dates, importantDateFromModel(model, strVal(memberName)))
}
return dates, rows.Err()
}
// var dates []ext.ImportantDate
// for rows.Next() {
// var model generatedmodels.ModelPublicImportantDates
// var memberName *string
// if err := rows.Scan(&model.ID, &model.FamilyMemberID, &memberName, &model.Title, &model.DateValue,
// &model.RecurringYearly, &model.ReminderDaysBefore, &model.Notes, &model.CreatedAt); err != nil {
// return nil, fmt.Errorf("scan important date: %w", err)
// }
// dates = append(dates, importantDateFromModel(model, strVal(memberName)))
// }
// return dates, rows.Err()
// }
func scanActivities(rows interface {
Next() bool
Scan(...any) error
Err() error
Close()
}) ([]ext.Activity, error) {
defer rows.Close()
var activities []ext.Activity
for rows.Next() {
var model generatedmodels.ModelPublicActivities
var memberName *string
if err := rows.Scan(
&model.ID, &model.FamilyMemberID, &memberName, &model.Title, &model.ActivityType,
&model.DayOfWeek, &model.StartTime, &model.EndTime,
&model.StartDate, &model.EndDate, &model.Location, &model.Notes, &model.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan activity: %w", err)
}
activities = append(activities, activityFromModel(model, strVal(memberName)))
}
return activities, rows.Err()
}
// func scanActivities(rows interface {
// Next() bool
// Scan(...any) error
// Err() error
// Close()
// }) ([]ext.Activity, error) {
// defer rows.Close()
// var activities []ext.Activity
// for rows.Next() {
// var model generatedmodels.ModelPublicActivities
// var memberName *string
// if err := rows.Scan(
// &model.ID, &model.FamilyMemberID, &memberName, &model.Title, &model.ActivityType,
// &model.DayOfWeek, &model.StartTime, &model.EndTime,
// &model.StartDate, &model.EndDate, &model.Location, &model.Notes, &model.CreatedAt,
// ); err != nil {
// return nil, fmt.Errorf("scan activity: %w", err)
// }
// activities = append(activities, activityFromModel(model, strVal(memberName)))
// }
// return activities, rows.Err()
// }

View File

@@ -1,235 +1,235 @@
package store
import (
"context"
"fmt"
"strings"
"time"
// import (
// "context"
// "fmt"
// "strings"
// "time"
"github.com/google/uuid"
// "github.com/google/uuid"
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
// "git.warky.dev/wdevs/amcs/internal/generatedmodels"
// ext "git.warky.dev/wdevs/amcs/internal/types"
// )
func (db *DB) AddProfessionalContact(ctx context.Context, c ext.ProfessionalContact) (ext.ProfessionalContact, error) {
if c.Tags == nil {
c.Tags = []string{}
}
// func (db *DB) AddProfessionalContact(ctx context.Context, c ext.ProfessionalContact) (ext.ProfessionalContact, error) {
// if c.Tags == nil {
// c.Tags = []string{}
// }
row := db.pool.QueryRow(ctx, `
insert into professional_contacts (name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, follow_up_date)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
returning id, created_at, updated_at
`, c.Name, nullStr(c.Company), nullStr(c.Title), nullStr(c.Email), nullStr(c.Phone),
nullStr(c.LinkedInURL), nullStr(c.HowWeMet), c.Tags, nullStr(c.Notes), c.FollowUpDate)
// row := db.pool.QueryRow(ctx, `
// insert into professional_contacts (name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, follow_up_date)
// values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
// returning id, created_at, updated_at
// `, c.Name, nullStr(c.Company), nullStr(c.Title), nullStr(c.Email), nullStr(c.Phone),
// nullStr(c.LinkedInURL), nullStr(c.HowWeMet), c.Tags, nullStr(c.Notes), c.FollowUpDate)
created := c
var model generatedmodels.ModelPublicProfessionalContacts
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
return ext.ProfessionalContact{}, fmt.Errorf("insert contact: %w", err)
}
created.ID = model.ID.UUID()
created.CreatedAt = model.CreatedAt.Time()
created.UpdatedAt = model.UpdatedAt.Time()
return created, nil
}
// created := c
// var model generatedmodels.ModelPublicProfessionalContacts
// if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
// return ext.ProfessionalContact{}, fmt.Errorf("insert contact: %w", err)
// }
// created.ID = model.ID.UUID()
// created.CreatedAt = model.CreatedAt.Time()
// created.UpdatedAt = model.UpdatedAt.Time()
// return created, nil
// }
func (db *DB) SearchContacts(ctx context.Context, query string, tags []string) ([]ext.ProfessionalContact, error) {
args := []any{}
conditions := []string{}
// func (db *DB) SearchContacts(ctx context.Context, query string, tags []string) ([]ext.ProfessionalContact, error) {
// args := []any{}
// conditions := []string{}
if q := strings.TrimSpace(query); q != "" {
args = append(args, "%"+q+"%")
idx := len(args)
conditions = append(conditions, fmt.Sprintf(
"(name ILIKE $%[1]d OR company ILIKE $%[1]d OR title ILIKE $%[1]d OR notes ILIKE $%[1]d)", idx))
}
if len(tags) > 0 {
args = append(args, tags)
conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
}
// if q := strings.TrimSpace(query); q != "" {
// args = append(args, "%"+q+"%")
// idx := len(args)
// conditions = append(conditions, fmt.Sprintf(
// "(name ILIKE $%[1]d OR company ILIKE $%[1]d OR title ILIKE $%[1]d OR notes ILIKE $%[1]d)", idx))
// }
// if len(tags) > 0 {
// args = append(args, tags)
// conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
// }
q := `select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at from professional_contacts`
if len(conditions) > 0 {
q += " where " + strings.Join(conditions, " and ")
}
q += " order by name"
// q := `select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at from professional_contacts`
// if len(conditions) > 0 {
// q += " where " + strings.Join(conditions, " and ")
// }
// q += " order by name"
rows, err := db.pool.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("search contacts: %w", err)
}
defer rows.Close()
// rows, err := db.pool.Query(ctx, q, args...)
// if err != nil {
// return nil, fmt.Errorf("search contacts: %w", err)
// }
// defer rows.Close()
return scanContacts(rows)
}
// return scanContacts(rows)
// }
func (db *DB) GetContact(ctx context.Context, id uuid.UUID) (ext.ProfessionalContact, error) {
row := db.pool.QueryRow(ctx, `
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at
from professional_contacts where id = $1
`, id)
// func (db *DB) GetContact(ctx context.Context, id uuid.UUID) (ext.ProfessionalContact, error) {
// row := db.pool.QueryRow(ctx, `
// select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at
// from professional_contacts where id = $1
// `, id)
var model generatedmodels.ModelPublicProfessionalContacts
var tags []string
if err := row.Scan(&model.ID, &model.Name, &model.Company, &model.Title, &model.Email, &model.Phone,
&model.LinkedinURL, &model.HowWeMet, &tags, &model.Notes, &model.LastContacted, &model.FollowUpDate,
&model.CreatedAt, &model.UpdatedAt); err != nil {
return ext.ProfessionalContact{}, fmt.Errorf("get contact: %w", err)
}
c := professionalContactFromModel(model, tags)
return c, nil
}
// var model generatedmodels.ModelPublicProfessionalContacts
// var tags []string
// if err := row.Scan(&model.ID, &model.Name, &model.Company, &model.Title, &model.Email, &model.Phone,
// &model.LinkedinURL, &model.HowWeMet, &tags, &model.Notes, &model.LastContacted, &model.FollowUpDate,
// &model.CreatedAt, &model.UpdatedAt); err != nil {
// return ext.ProfessionalContact{}, fmt.Errorf("get contact: %w", err)
// }
// c := professionalContactFromModel(model, tags)
// return c, nil
// }
func (db *DB) LogInteraction(ctx context.Context, interaction ext.ContactInteraction) (ext.ContactInteraction, error) {
occurredAt := interaction.OccurredAt
if occurredAt.IsZero() {
occurredAt = time.Now()
}
// func (db *DB) LogInteraction(ctx context.Context, interaction ext.ContactInteraction) (ext.ContactInteraction, error) {
// occurredAt := interaction.OccurredAt
// if occurredAt.IsZero() {
// occurredAt = time.Now()
// }
row := db.pool.QueryRow(ctx, `
insert into contact_interactions (contact_id, interaction_type, occurred_at, summary, follow_up_needed, follow_up_notes)
values ($1, $2, $3, $4, $5, $6)
returning id, created_at
`, interaction.ContactID, interaction.InteractionType, occurredAt, interaction.Summary,
interaction.FollowUpNeeded, nullStr(interaction.FollowUpNotes))
// row := db.pool.QueryRow(ctx, `
// insert into contact_interactions (contact_id, interaction_type, occurred_at, summary, follow_up_needed, follow_up_notes)
// values ($1, $2, $3, $4, $5, $6)
// returning id, created_at
// `, interaction.ContactID, interaction.InteractionType, occurredAt, interaction.Summary,
// interaction.FollowUpNeeded, nullStr(interaction.FollowUpNotes))
created := interaction
created.OccurredAt = occurredAt
var model generatedmodels.ModelPublicContactInteractions
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
return ext.ContactInteraction{}, fmt.Errorf("insert interaction: %w", err)
}
created.ID = model.ID.UUID()
created.CreatedAt = model.CreatedAt.Time()
return created, nil
}
// created := interaction
// created.OccurredAt = occurredAt
// var model generatedmodels.ModelPublicContactInteractions
// if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
// return ext.ContactInteraction{}, fmt.Errorf("insert interaction: %w", err)
// }
// created.ID = model.ID.UUID()
// created.CreatedAt = model.CreatedAt.Time()
// return created, nil
// }
func (db *DB) GetContactHistory(ctx context.Context, contactID uuid.UUID) (ext.ContactHistory, error) {
contact, err := db.GetContact(ctx, contactID)
if err != nil {
return ext.ContactHistory{}, err
}
// func (db *DB) GetContactHistory(ctx context.Context, contactID uuid.UUID) (ext.ContactHistory, error) {
// contact, err := db.GetContact(ctx, contactID)
// if err != nil {
// return ext.ContactHistory{}, err
// }
rows, err := db.pool.Query(ctx, `
select id, contact_id, interaction_type, occurred_at, summary, follow_up_needed, follow_up_notes, created_at
from contact_interactions where contact_id = $1 order by occurred_at desc
`, contactID)
if err != nil {
return ext.ContactHistory{}, fmt.Errorf("get interactions: %w", err)
}
defer rows.Close()
// rows, err := db.pool.Query(ctx, `
// select id, contact_id, interaction_type, occurred_at, summary, follow_up_needed, follow_up_notes, created_at
// from contact_interactions where contact_id = $1 order by occurred_at desc
// `, contactID)
// if err != nil {
// return ext.ContactHistory{}, fmt.Errorf("get interactions: %w", err)
// }
// defer rows.Close()
var interactions []ext.ContactInteraction
for rows.Next() {
var model generatedmodels.ModelPublicContactInteractions
if err := rows.Scan(&model.ID, &model.ContactID, &model.InteractionType, &model.OccurredAt, &model.Summary,
&model.FollowUpNeeded, &model.FollowUpNotes, &model.CreatedAt); err != nil {
return ext.ContactHistory{}, fmt.Errorf("scan interaction: %w", err)
}
interactions = append(interactions, contactInteractionFromModel(model))
}
if err := rows.Err(); err != nil {
return ext.ContactHistory{}, err
}
// var interactions []ext.ContactInteraction
// for rows.Next() {
// var model generatedmodels.ModelPublicContactInteractions
// if err := rows.Scan(&model.ID, &model.ContactID, &model.InteractionType, &model.OccurredAt, &model.Summary,
// &model.FollowUpNeeded, &model.FollowUpNotes, &model.CreatedAt); err != nil {
// return ext.ContactHistory{}, fmt.Errorf("scan interaction: %w", err)
// }
// interactions = append(interactions, contactInteractionFromModel(model))
// }
// if err := rows.Err(); err != nil {
// return ext.ContactHistory{}, err
// }
oppRows, err := db.pool.Query(ctx, `
select id, contact_id, title, description, stage, value, expected_close_date, notes, created_at, updated_at
from opportunities where contact_id = $1 order by created_at desc
`, contactID)
if err != nil {
return ext.ContactHistory{}, fmt.Errorf("get opportunities: %w", err)
}
defer oppRows.Close()
// oppRows, err := db.pool.Query(ctx, `
// select id, contact_id, title, description, stage, value, expected_close_date, notes, created_at, updated_at
// from opportunities where contact_id = $1 order by created_at desc
// `, contactID)
// if err != nil {
// return ext.ContactHistory{}, fmt.Errorf("get opportunities: %w", err)
// }
// defer oppRows.Close()
var opportunities []ext.Opportunity
for oppRows.Next() {
var model generatedmodels.ModelPublicOpportunities
if err := oppRows.Scan(&model.ID, &model.ContactID, &model.Title, &model.Description, &model.Stage, &model.Value,
&model.ExpectedCloseDate, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
return ext.ContactHistory{}, fmt.Errorf("scan opportunity: %w", err)
}
opportunities = append(opportunities, opportunityFromModel(model))
}
if err := oppRows.Err(); err != nil {
return ext.ContactHistory{}, err
}
// var opportunities []ext.Opportunity
// for oppRows.Next() {
// var model generatedmodels.ModelPublicOpportunities
// if err := oppRows.Scan(&model.ID, &model.ContactID, &model.Title, &model.Description, &model.Stage, &model.Value,
// &model.ExpectedCloseDate, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
// return ext.ContactHistory{}, fmt.Errorf("scan opportunity: %w", err)
// }
// opportunities = append(opportunities, opportunityFromModel(model))
// }
// if err := oppRows.Err(); err != nil {
// return ext.ContactHistory{}, err
// }
return ext.ContactHistory{
Contact: contact,
Interactions: interactions,
Opportunities: opportunities,
}, nil
}
// return ext.ContactHistory{
// Contact: contact,
// Interactions: interactions,
// Opportunities: opportunities,
// }, nil
// }
func (db *DB) CreateOpportunity(ctx context.Context, o ext.Opportunity) (ext.Opportunity, error) {
row := db.pool.QueryRow(ctx, `
insert into opportunities (contact_id, title, description, stage, value, expected_close_date, notes)
values ($1, $2, $3, $4, $5, $6, $7)
returning id, created_at, updated_at
`, o.ContactID, o.Title, nullStr(o.Description), o.Stage, o.Value, o.ExpectedCloseDate, nullStr(o.Notes))
// func (db *DB) CreateOpportunity(ctx context.Context, o ext.Opportunity) (ext.Opportunity, error) {
// row := db.pool.QueryRow(ctx, `
// insert into opportunities (contact_id, title, description, stage, value, expected_close_date, notes)
// values ($1, $2, $3, $4, $5, $6, $7)
// returning id, created_at, updated_at
// `, o.ContactID, o.Title, nullStr(o.Description), o.Stage, o.Value, o.ExpectedCloseDate, nullStr(o.Notes))
created := o
var model generatedmodels.ModelPublicOpportunities
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
return ext.Opportunity{}, fmt.Errorf("insert opportunity: %w", err)
}
created.ID = model.ID.UUID()
created.CreatedAt = model.CreatedAt.Time()
created.UpdatedAt = model.UpdatedAt.Time()
return created, nil
}
// created := o
// var model generatedmodels.ModelPublicOpportunities
// if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
// return ext.Opportunity{}, fmt.Errorf("insert opportunity: %w", err)
// }
// created.ID = model.ID.UUID()
// created.CreatedAt = model.CreatedAt.Time()
// created.UpdatedAt = model.UpdatedAt.Time()
// return created, nil
// }
func (db *DB) GetFollowUpsDue(ctx context.Context, daysAhead int) ([]ext.ProfessionalContact, error) {
if daysAhead <= 0 {
daysAhead = 7
}
cutoff := time.Now().AddDate(0, 0, daysAhead)
// func (db *DB) GetFollowUpsDue(ctx context.Context, daysAhead int) ([]ext.ProfessionalContact, error) {
// if daysAhead <= 0 {
// daysAhead = 7
// }
// cutoff := time.Now().AddDate(0, 0, daysAhead)
rows, err := db.pool.Query(ctx, `
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at
from professional_contacts
where follow_up_date <= $1
order by follow_up_date asc
`, cutoff)
if err != nil {
return nil, fmt.Errorf("get follow-ups: %w", err)
}
defer rows.Close()
// rows, err := db.pool.Query(ctx, `
// select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at
// from professional_contacts
// where follow_up_date <= $1
// order by follow_up_date asc
// `, cutoff)
// if err != nil {
// return nil, fmt.Errorf("get follow-ups: %w", err)
// }
// defer rows.Close()
return scanContacts(rows)
}
// return scanContacts(rows)
// }
func (db *DB) AppendThoughtToContactNotes(ctx context.Context, contactID uuid.UUID, thoughtContent string) error {
_, err := db.pool.Exec(ctx, `
update professional_contacts
set notes = coalesce(notes, '') || $2
where id = $1
`, contactID, thoughtContent)
if err != nil {
return fmt.Errorf("append thought to contact: %w", err)
}
return nil
}
// func (db *DB) AppendThoughtToContactNotes(ctx context.Context, contactID uuid.UUID, thoughtContent string) error {
// _, err := db.pool.Exec(ctx, `
// update professional_contacts
// set notes = coalesce(notes, '') || $2
// where id = $1
// `, contactID, thoughtContent)
// if err != nil {
// return fmt.Errorf("append thought to contact: %w", err)
// }
// return nil
// }
func scanContacts(rows interface {
Next() bool
Scan(...any) error
Err() error
Close()
}) ([]ext.ProfessionalContact, error) {
defer rows.Close()
var contacts []ext.ProfessionalContact
for rows.Next() {
var model generatedmodels.ModelPublicProfessionalContacts
var tags []string
if err := rows.Scan(&model.ID, &model.Name, &model.Company, &model.Title, &model.Email, &model.Phone,
&model.LinkedinURL, &model.HowWeMet, &tags, &model.Notes, &model.LastContacted, &model.FollowUpDate,
&model.CreatedAt, &model.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan contact: %w", err)
}
contacts = append(contacts, professionalContactFromModel(model, tags))
}
return contacts, rows.Err()
}
// func scanContacts(rows interface {
// Next() bool
// Scan(...any) error
// Err() error
// Close()
// }) ([]ext.ProfessionalContact, error) {
// defer rows.Close()
// var contacts []ext.ProfessionalContact
// for rows.Next() {
// var model generatedmodels.ModelPublicProfessionalContacts
// var tags []string
// if err := rows.Scan(&model.ID, &model.Name, &model.Company, &model.Title, &model.Email, &model.Phone,
// &model.LinkedinURL, &model.HowWeMet, &tags, &model.Notes, &model.LastContacted, &model.FollowUpDate,
// &model.CreatedAt, &model.UpdatedAt); err != nil {
// return nil, fmt.Errorf("scan contact: %w", err)
// }
// contacts = append(contacts, professionalContactFromModel(model, tags))
// }
// return contacts, rows.Err()
// }

View File

@@ -1,133 +1,133 @@
package store
import (
"context"
"encoding/json"
"fmt"
"strings"
// import (
// "context"
// "encoding/json"
// "fmt"
// "strings"
"github.com/google/uuid"
// "github.com/google/uuid"
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
// "git.warky.dev/wdevs/amcs/internal/generatedmodels"
// ext "git.warky.dev/wdevs/amcs/internal/types"
// )
func (db *DB) AddHouseholdItem(ctx context.Context, item ext.HouseholdItem) (ext.HouseholdItem, error) {
details, err := json.Marshal(item.Details)
if err != nil {
return ext.HouseholdItem{}, fmt.Errorf("marshal details: %w", err)
}
// func (db *DB) AddHouseholdItem(ctx context.Context, item ext.HouseholdItem) (ext.HouseholdItem, error) {
// details, err := json.Marshal(item.Details)
// if err != nil {
// return ext.HouseholdItem{}, fmt.Errorf("marshal details: %w", err)
// }
row := db.pool.QueryRow(ctx, `
insert into household_items (name, category, location, details, notes)
values ($1, $2, $3, $4::jsonb, $5)
returning id, created_at, updated_at
`, item.Name, nullStr(item.Category), nullStr(item.Location), details, nullStr(item.Notes))
// row := db.pool.QueryRow(ctx, `
// insert into household_items (name, category, location, details, notes)
// values ($1, $2, $3, $4::jsonb, $5)
// returning id, created_at, updated_at
// `, item.Name, nullStr(item.Category), nullStr(item.Location), details, nullStr(item.Notes))
created := item
var model generatedmodels.ModelPublicHouseholdItems
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
return ext.HouseholdItem{}, fmt.Errorf("insert household item: %w", err)
}
created.ID = model.ID.UUID()
created.CreatedAt = model.CreatedAt.Time()
created.UpdatedAt = model.UpdatedAt.Time()
return created, nil
}
// created := item
// var model generatedmodels.ModelPublicHouseholdItems
// if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
// return ext.HouseholdItem{}, fmt.Errorf("insert household item: %w", err)
// }
// created.ID = model.ID.UUID()
// created.CreatedAt = model.CreatedAt.Time()
// created.UpdatedAt = model.UpdatedAt.Time()
// return created, nil
// }
func (db *DB) SearchHouseholdItems(ctx context.Context, query, category, location string) ([]ext.HouseholdItem, error) {
args := []any{}
conditions := []string{}
// func (db *DB) SearchHouseholdItems(ctx context.Context, query, category, location string) ([]ext.HouseholdItem, error) {
// args := []any{}
// conditions := []string{}
if q := strings.TrimSpace(query); q != "" {
args = append(args, "%"+q+"%")
conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d OR notes ILIKE $%d)", len(args), len(args)))
}
if c := strings.TrimSpace(category); c != "" {
args = append(args, c)
conditions = append(conditions, fmt.Sprintf("category = $%d", len(args)))
}
if l := strings.TrimSpace(location); l != "" {
args = append(args, "%"+l+"%")
conditions = append(conditions, fmt.Sprintf("location ILIKE $%d", len(args)))
}
// if q := strings.TrimSpace(query); q != "" {
// args = append(args, "%"+q+"%")
// conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d OR notes ILIKE $%d)", len(args), len(args)))
// }
// if c := strings.TrimSpace(category); c != "" {
// args = append(args, c)
// conditions = append(conditions, fmt.Sprintf("category = $%d", len(args)))
// }
// if l := strings.TrimSpace(location); l != "" {
// args = append(args, "%"+l+"%")
// conditions = append(conditions, fmt.Sprintf("location ILIKE $%d", len(args)))
// }
q := `select id, name, category, location, details, notes, created_at, updated_at from household_items`
if len(conditions) > 0 {
q += " where " + strings.Join(conditions, " and ")
}
q += " order by name"
// q := `select id, name, category, location, details, notes, created_at, updated_at from household_items`
// if len(conditions) > 0 {
// q += " where " + strings.Join(conditions, " and ")
// }
// q += " order by name"
rows, err := db.pool.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("search household items: %w", err)
}
defer rows.Close()
// rows, err := db.pool.Query(ctx, q, args...)
// if err != nil {
// return nil, fmt.Errorf("search household items: %w", err)
// }
// defer rows.Close()
var items []ext.HouseholdItem
for rows.Next() {
var model generatedmodels.ModelPublicHouseholdItems
if err := rows.Scan(&model.ID, &model.Name, &model.Category, &model.Location, &model.Details, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan household item: %w", err)
}
items = append(items, householdItemFromModel(model))
}
return items, rows.Err()
}
// var items []ext.HouseholdItem
// for rows.Next() {
// var model generatedmodels.ModelPublicHouseholdItems
// if err := rows.Scan(&model.ID, &model.Name, &model.Category, &model.Location, &model.Details, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
// return nil, fmt.Errorf("scan household item: %w", err)
// }
// items = append(items, householdItemFromModel(model))
// }
// return items, rows.Err()
// }
func (db *DB) GetHouseholdItem(ctx context.Context, id uuid.UUID) (ext.HouseholdItem, error) {
row := db.pool.QueryRow(ctx, `
select id, name, category, location, details, notes, created_at, updated_at
from household_items where id = $1
`, id)
// func (db *DB) GetHouseholdItem(ctx context.Context, id uuid.UUID) (ext.HouseholdItem, error) {
// row := db.pool.QueryRow(ctx, `
// select id, name, category, location, details, notes, created_at, updated_at
// from household_items where id = $1
// `, id)
var model generatedmodels.ModelPublicHouseholdItems
if err := row.Scan(&model.ID, &model.Name, &model.Category, &model.Location, &model.Details, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
return ext.HouseholdItem{}, fmt.Errorf("get household item: %w", err)
}
return householdItemFromModel(model), nil
}
// var model generatedmodels.ModelPublicHouseholdItems
// if err := row.Scan(&model.ID, &model.Name, &model.Category, &model.Location, &model.Details, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
// return ext.HouseholdItem{}, fmt.Errorf("get household item: %w", err)
// }
// return householdItemFromModel(model), nil
// }
func (db *DB) AddVendor(ctx context.Context, v ext.HouseholdVendor) (ext.HouseholdVendor, error) {
row := db.pool.QueryRow(ctx, `
insert into household_vendors (name, service_type, phone, email, website, notes, rating, last_used)
values ($1, $2, $3, $4, $5, $6, $7, $8)
returning id, created_at
`, v.Name, nullStr(v.ServiceType), nullStr(v.Phone), nullStr(v.Email),
nullStr(v.Website), nullStr(v.Notes), v.Rating, v.LastUsed)
// func (db *DB) AddVendor(ctx context.Context, v ext.HouseholdVendor) (ext.HouseholdVendor, error) {
// row := db.pool.QueryRow(ctx, `
// insert into household_vendors (name, service_type, phone, email, website, notes, rating, last_used)
// values ($1, $2, $3, $4, $5, $6, $7, $8)
// returning id, created_at
// `, v.Name, nullStr(v.ServiceType), nullStr(v.Phone), nullStr(v.Email),
// nullStr(v.Website), nullStr(v.Notes), v.Rating, v.LastUsed)
created := v
var model generatedmodels.ModelPublicHouseholdVendors
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
return ext.HouseholdVendor{}, fmt.Errorf("insert vendor: %w", err)
}
created.ID = model.ID.UUID()
created.CreatedAt = model.CreatedAt.Time()
return created, nil
}
// created := v
// var model generatedmodels.ModelPublicHouseholdVendors
// if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
// return ext.HouseholdVendor{}, fmt.Errorf("insert vendor: %w", err)
// }
// created.ID = model.ID.UUID()
// created.CreatedAt = model.CreatedAt.Time()
// return created, nil
// }
func (db *DB) ListVendors(ctx context.Context, serviceType string) ([]ext.HouseholdVendor, error) {
args := []any{}
q := `select id, name, service_type, phone, email, website, notes, rating, last_used, created_at from household_vendors`
if st := strings.TrimSpace(serviceType); st != "" {
args = append(args, st)
q += " where service_type = $1"
}
q += " order by name"
// func (db *DB) ListVendors(ctx context.Context, serviceType string) ([]ext.HouseholdVendor, error) {
// args := []any{}
// q := `select id, name, service_type, phone, email, website, notes, rating, last_used, created_at from household_vendors`
// if st := strings.TrimSpace(serviceType); st != "" {
// args = append(args, st)
// q += " where service_type = $1"
// }
// q += " order by name"
rows, err := db.pool.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list vendors: %w", err)
}
defer rows.Close()
// rows, err := db.pool.Query(ctx, q, args...)
// if err != nil {
// return nil, fmt.Errorf("list vendors: %w", err)
// }
// defer rows.Close()
var vendors []ext.HouseholdVendor
for rows.Next() {
var model generatedmodels.ModelPublicHouseholdVendors
if err := rows.Scan(&model.ID, &model.Name, &model.ServiceType, &model.Phone, &model.Email, &model.Website, &model.Notes, &model.Rating, &model.LastUsed, &model.CreatedAt); err != nil {
return nil, fmt.Errorf("scan vendor: %w", err)
}
vendors = append(vendors, householdVendorFromModel(model))
}
return vendors, rows.Err()
}
// var vendors []ext.HouseholdVendor
// for rows.Next() {
// var model generatedmodels.ModelPublicHouseholdVendors
// if err := rows.Scan(&model.ID, &model.Name, &model.ServiceType, &model.Phone, &model.Email, &model.Website, &model.Notes, &model.Rating, &model.LastUsed, &model.CreatedAt); err != nil {
// return nil, fmt.Errorf("scan vendor: %w", err)
// }
// vendors = append(vendors, householdVendorFromModel(model))
// }
// return vendors, rows.Err()
// }

View File

@@ -1,137 +1,137 @@
package store
import (
"context"
"fmt"
"strings"
"time"
// import (
// "context"
// "fmt"
// "strings"
// "time"
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
// "git.warky.dev/wdevs/amcs/internal/generatedmodels"
// ext "git.warky.dev/wdevs/amcs/internal/types"
// )
func (db *DB) AddMaintenanceTask(ctx context.Context, t ext.MaintenanceTask) (ext.MaintenanceTask, error) {
row := db.pool.QueryRow(ctx, `
insert into maintenance_tasks (name, category, frequency_days, next_due, priority, notes)
values ($1, $2, $3, $4, $5, $6)
returning id, created_at, updated_at
`, t.Name, nullStr(t.Category), t.FrequencyDays, t.NextDue, t.Priority, nullStr(t.Notes))
// func (db *DB) AddMaintenanceTask(ctx context.Context, t ext.MaintenanceTask) (ext.MaintenanceTask, error) {
// row := db.pool.QueryRow(ctx, `
// insert into maintenance_tasks (name, category, frequency_days, next_due, priority, notes)
// values ($1, $2, $3, $4, $5, $6)
// returning id, created_at, updated_at
// `, t.Name, nullStr(t.Category), t.FrequencyDays, t.NextDue, t.Priority, nullStr(t.Notes))
created := t
var model generatedmodels.ModelPublicMaintenanceTasks
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
return ext.MaintenanceTask{}, fmt.Errorf("insert maintenance task: %w", err)
}
created.ID = model.ID.UUID()
created.CreatedAt = model.CreatedAt.Time()
created.UpdatedAt = model.UpdatedAt.Time()
return created, nil
}
// created := t
// var model generatedmodels.ModelPublicMaintenanceTasks
// if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
// return ext.MaintenanceTask{}, fmt.Errorf("insert maintenance task: %w", err)
// }
// created.ID = model.ID.UUID()
// created.CreatedAt = model.CreatedAt.Time()
// created.UpdatedAt = model.UpdatedAt.Time()
// return created, nil
// }
func (db *DB) LogMaintenance(ctx context.Context, log ext.MaintenanceLog) (ext.MaintenanceLog, error) {
completedAt := log.CompletedAt
if completedAt.IsZero() {
completedAt = time.Now()
}
// func (db *DB) LogMaintenance(ctx context.Context, log ext.MaintenanceLog) (ext.MaintenanceLog, error) {
// completedAt := log.CompletedAt
// if completedAt.IsZero() {
// completedAt = time.Now()
// }
row := db.pool.QueryRow(ctx, `
insert into maintenance_logs (task_id, completed_at, performed_by, cost, notes, next_action)
values ($1, $2, $3, $4, $5, $6)
returning id
`, log.TaskID, completedAt, nullStr(log.PerformedBy), log.Cost, nullStr(log.Notes), nullStr(log.NextAction))
// row := db.pool.QueryRow(ctx, `
// insert into maintenance_logs (task_id, completed_at, performed_by, cost, notes, next_action)
// values ($1, $2, $3, $4, $5, $6)
// returning id
// `, log.TaskID, completedAt, nullStr(log.PerformedBy), log.Cost, nullStr(log.Notes), nullStr(log.NextAction))
created := log
created.CompletedAt = completedAt
var model generatedmodels.ModelPublicMaintenanceLogs
if err := row.Scan(&model.ID); err != nil {
return ext.MaintenanceLog{}, fmt.Errorf("insert maintenance log: %w", err)
}
created.ID = model.ID.UUID()
return created, nil
}
// created := log
// created.CompletedAt = completedAt
// var model generatedmodels.ModelPublicMaintenanceLogs
// if err := row.Scan(&model.ID); err != nil {
// return ext.MaintenanceLog{}, fmt.Errorf("insert maintenance log: %w", err)
// }
// created.ID = model.ID.UUID()
// return created, nil
// }
func (db *DB) GetUpcomingMaintenance(ctx context.Context, daysAhead int) ([]ext.MaintenanceTask, error) {
if daysAhead <= 0 {
daysAhead = 30
}
cutoff := time.Now().Add(time.Duration(daysAhead) * 24 * time.Hour)
// func (db *DB) GetUpcomingMaintenance(ctx context.Context, daysAhead int) ([]ext.MaintenanceTask, error) {
// if daysAhead <= 0 {
// daysAhead = 30
// }
// cutoff := time.Now().Add(time.Duration(daysAhead) * 24 * time.Hour)
rows, err := db.pool.Query(ctx, `
select id, name, category, frequency_days, last_completed, next_due, priority, notes, created_at, updated_at
from maintenance_tasks
where next_due <= $1 or next_due is null
order by next_due asc nulls last, priority desc
`, cutoff)
if err != nil {
return nil, fmt.Errorf("get upcoming maintenance: %w", err)
}
defer rows.Close()
// rows, err := db.pool.Query(ctx, `
// select id, name, category, frequency_days, last_completed, next_due, priority, notes, created_at, updated_at
// from maintenance_tasks
// where next_due <= $1 or next_due is null
// order by next_due asc nulls last, priority desc
// `, cutoff)
// if err != nil {
// return nil, fmt.Errorf("get upcoming maintenance: %w", err)
// }
// defer rows.Close()
tasks := make([]ext.MaintenanceTask, 0)
for rows.Next() {
var model generatedmodels.ModelPublicMaintenanceTasks
if err := rows.Scan(&model.ID, &model.Name, &model.Category, &model.FrequencyDays, &model.LastCompleted, &model.NextDue, &model.Priority, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan maintenance task: %w", err)
}
tasks = append(tasks, maintenanceTaskFromModel(model))
}
return tasks, rows.Err()
}
// tasks := make([]ext.MaintenanceTask, 0)
// for rows.Next() {
// var model generatedmodels.ModelPublicMaintenanceTasks
// if err := rows.Scan(&model.ID, &model.Name, &model.Category, &model.FrequencyDays, &model.LastCompleted, &model.NextDue, &model.Priority, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
// return nil, fmt.Errorf("scan maintenance task: %w", err)
// }
// tasks = append(tasks, maintenanceTaskFromModel(model))
// }
// return tasks, rows.Err()
// }
func (db *DB) SearchMaintenanceHistory(ctx context.Context, query, category string, start, end *time.Time) ([]ext.MaintenanceLogWithTask, error) {
args := []any{}
conditions := []string{}
// func (db *DB) SearchMaintenanceHistory(ctx context.Context, query, category string, start, end *time.Time) ([]ext.MaintenanceLogWithTask, error) {
// args := []any{}
// conditions := []string{}
if q := strings.TrimSpace(query); q != "" {
args = append(args, "%"+q+"%")
conditions = append(conditions, fmt.Sprintf("(mt.name ILIKE $%d OR ml.notes ILIKE $%d)", len(args), len(args)))
}
if c := strings.TrimSpace(category); c != "" {
args = append(args, c)
conditions = append(conditions, fmt.Sprintf("mt.category = $%d", len(args)))
}
if start != nil {
args = append(args, *start)
conditions = append(conditions, fmt.Sprintf("ml.completed_at >= $%d", len(args)))
}
if end != nil {
args = append(args, *end)
conditions = append(conditions, fmt.Sprintf("ml.completed_at <= $%d", len(args)))
}
// if q := strings.TrimSpace(query); q != "" {
// args = append(args, "%"+q+"%")
// conditions = append(conditions, fmt.Sprintf("(mt.name ILIKE $%d OR ml.notes ILIKE $%d)", len(args), len(args)))
// }
// if c := strings.TrimSpace(category); c != "" {
// args = append(args, c)
// conditions = append(conditions, fmt.Sprintf("mt.category = $%d", len(args)))
// }
// if start != nil {
// args = append(args, *start)
// conditions = append(conditions, fmt.Sprintf("ml.completed_at >= $%d", len(args)))
// }
// if end != nil {
// args = append(args, *end)
// conditions = append(conditions, fmt.Sprintf("ml.completed_at <= $%d", len(args)))
// }
q := `
select ml.id, ml.task_id, ml.completed_at, ml.performed_by, ml.cost, ml.notes, ml.next_action,
mt.name, mt.category
from maintenance_logs ml
join maintenance_tasks mt on mt.id = ml.task_id
`
if len(conditions) > 0 {
q += " where " + strings.Join(conditions, " and ")
}
q += " order by ml.completed_at desc"
// q := `
// select ml.id, ml.task_id, ml.completed_at, ml.performed_by, ml.cost, ml.notes, ml.next_action,
// mt.name, mt.category
// from maintenance_logs ml
// join maintenance_tasks mt on mt.id = ml.task_id
// `
// if len(conditions) > 0 {
// q += " where " + strings.Join(conditions, " and ")
// }
// q += " order by ml.completed_at desc"
rows, err := db.pool.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("search maintenance history: %w", err)
}
defer rows.Close()
// rows, err := db.pool.Query(ctx, q, args...)
// if err != nil {
// return nil, fmt.Errorf("search maintenance history: %w", err)
// }
// defer rows.Close()
var logs []ext.MaintenanceLogWithTask
for rows.Next() {
var model generatedmodels.ModelPublicMaintenanceLogs
var taskName, taskCategory string
if err := rows.Scan(
&model.ID, &model.TaskID, &model.CompletedAt, &model.PerformedBy, &model.Cost, &model.Notes, &model.NextAction,
&taskName, &taskCategory,
); err != nil {
return nil, fmt.Errorf("scan maintenance log: %w", err)
}
l := ext.MaintenanceLogWithTask{
MaintenanceLog: maintenanceLogFromModel(model),
TaskName: taskName,
TaskCategory: taskCategory,
}
logs = append(logs, l)
}
return logs, rows.Err()
}
// var logs []ext.MaintenanceLogWithTask
// for rows.Next() {
// var model generatedmodels.ModelPublicMaintenanceLogs
// var taskName, taskCategory string
// if err := rows.Scan(
// &model.ID, &model.TaskID, &model.CompletedAt, &model.PerformedBy, &model.Cost, &model.Notes, &model.NextAction,
// &taskName, &taskCategory,
// ); err != nil {
// return nil, fmt.Errorf("scan maintenance log: %w", err)
// }
// l := ext.MaintenanceLogWithTask{
// MaintenanceLog: maintenanceLogFromModel(model),
// TaskName: taskName,
// TaskCategory: taskCategory,
// }
// logs = append(logs, l)
// }
// return logs, rows.Err()
// }

View File

@@ -1,280 +1,280 @@
package store
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
// import (
// "context"
// "encoding/json"
// "fmt"
// "strings"
// "time"
"github.com/google/uuid"
// "github.com/google/uuid"
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
// "git.warky.dev/wdevs/amcs/internal/generatedmodels"
// ext "git.warky.dev/wdevs/amcs/internal/types"
// )
func (db *DB) AddRecipe(ctx context.Context, r ext.Recipe) (ext.Recipe, error) {
ingredients, err := json.Marshal(r.Ingredients)
if err != nil {
return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err)
}
instructions, err := json.Marshal(r.Instructions)
if err != nil {
return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err)
}
if r.Tags == nil {
r.Tags = []string{}
}
// func (db *DB) AddRecipe(ctx context.Context, r ext.Recipe) (ext.Recipe, error) {
// ingredients, err := json.Marshal(r.Ingredients)
// if err != nil {
// return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err)
// }
// instructions, err := json.Marshal(r.Instructions)
// if err != nil {
// return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err)
// }
// if r.Tags == nil {
// r.Tags = []string{}
// }
row := db.pool.QueryRow(ctx, `
insert into recipes (name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes)
values ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10)
returning id, created_at, updated_at
`, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings,
ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes))
// row := db.pool.QueryRow(ctx, `
// insert into recipes (name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes)
// values ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10)
// returning id, created_at, updated_at
// `, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings,
// ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes))
created := r
var model generatedmodels.ModelPublicRecipes
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
return ext.Recipe{}, fmt.Errorf("insert recipe: %w", err)
}
created.ID = model.ID.UUID()
created.CreatedAt = model.CreatedAt.Time()
created.UpdatedAt = model.UpdatedAt.Time()
return created, nil
}
// created := r
// var model generatedmodels.ModelPublicRecipes
// if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
// return ext.Recipe{}, fmt.Errorf("insert recipe: %w", err)
// }
// created.ID = model.ID.UUID()
// created.CreatedAt = model.CreatedAt.Time()
// created.UpdatedAt = model.UpdatedAt.Time()
// return created, nil
// }
func (db *DB) SearchRecipes(ctx context.Context, query, cuisine string, tags []string, ingredient string) ([]ext.Recipe, error) {
args := []any{}
conditions := []string{}
// func (db *DB) SearchRecipes(ctx context.Context, query, cuisine string, tags []string, ingredient string) ([]ext.Recipe, error) {
// args := []any{}
// conditions := []string{}
if q := strings.TrimSpace(query); q != "" {
args = append(args, "%"+q+"%")
conditions = append(conditions, fmt.Sprintf("name ILIKE $%d", len(args)))
}
if c := strings.TrimSpace(cuisine); c != "" {
args = append(args, c)
conditions = append(conditions, fmt.Sprintf("cuisine = $%d", len(args)))
}
if len(tags) > 0 {
args = append(args, tags)
conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
}
if ing := strings.TrimSpace(ingredient); ing != "" {
args = append(args, "%"+ing+"%")
conditions = append(conditions, fmt.Sprintf("ingredients::text ILIKE $%d", len(args)))
}
// if q := strings.TrimSpace(query); q != "" {
// args = append(args, "%"+q+"%")
// conditions = append(conditions, fmt.Sprintf("name ILIKE $%d", len(args)))
// }
// if c := strings.TrimSpace(cuisine); c != "" {
// args = append(args, c)
// conditions = append(conditions, fmt.Sprintf("cuisine = $%d", len(args)))
// }
// if len(tags) > 0 {
// args = append(args, tags)
// conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
// }
// if ing := strings.TrimSpace(ingredient); ing != "" {
// args = append(args, "%"+ing+"%")
// conditions = append(conditions, fmt.Sprintf("ingredients::text ILIKE $%d", len(args)))
// }
q := `select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags::text[], rating, notes, created_at, updated_at from recipes`
if len(conditions) > 0 {
q += " where " + strings.Join(conditions, " and ")
}
q += " order by name"
// q := `select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags::text[], rating, notes, created_at, updated_at from recipes`
// if len(conditions) > 0 {
// q += " where " + strings.Join(conditions, " and ")
// }
// q += " order by name"
rows, err := db.pool.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("search recipes: %w", err)
}
defer rows.Close()
// rows, err := db.pool.Query(ctx, q, args...)
// if err != nil {
// return nil, fmt.Errorf("search recipes: %w", err)
// }
// defer rows.Close()
var recipes []ext.Recipe
for rows.Next() {
r, err := scanRecipeRow(rows)
if err != nil {
return nil, err
}
recipes = append(recipes, r)
}
return recipes, rows.Err()
}
// var recipes []ext.Recipe
// for rows.Next() {
// r, err := scanRecipeRow(rows)
// if err != nil {
// return nil, err
// }
// recipes = append(recipes, r)
// }
// return recipes, rows.Err()
// }
func (db *DB) GetRecipe(ctx context.Context, id uuid.UUID) (ext.Recipe, error) {
row := db.pool.QueryRow(ctx, `
select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags::text[], rating, notes, created_at, updated_at
from recipes where id = $1
`, id)
// func (db *DB) GetRecipe(ctx context.Context, id uuid.UUID) (ext.Recipe, error) {
// row := db.pool.QueryRow(ctx, `
// select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags::text[], rating, notes, created_at, updated_at
// from recipes where id = $1
// `, id)
var model generatedmodels.ModelPublicRecipes
var tags []string
if err := row.Scan(&model.ID, &model.Name, &model.Cuisine, &model.PrepTimeMinutes, &model.CookTimeMinutes, &model.Servings,
&model.Ingredients, &model.Instructions, &tags, &model.Rating, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
return ext.Recipe{}, fmt.Errorf("get recipe: %w", err)
}
if tags == nil {
tags = []string{}
}
return recipeFromModel(model, tags), nil
}
// var model generatedmodels.ModelPublicRecipes
// var tags []string
// if err := row.Scan(&model.ID, &model.Name, &model.Cuisine, &model.PrepTimeMinutes, &model.CookTimeMinutes, &model.Servings,
// &model.Ingredients, &model.Instructions, &tags, &model.Rating, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
// return ext.Recipe{}, fmt.Errorf("get recipe: %w", err)
// }
// if tags == nil {
// tags = []string{}
// }
// return recipeFromModel(model, tags), nil
// }
func (db *DB) UpdateRecipe(ctx context.Context, id uuid.UUID, r ext.Recipe) (ext.Recipe, error) {
ingredients, err := json.Marshal(r.Ingredients)
if err != nil {
return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err)
}
instructions, err := json.Marshal(r.Instructions)
if err != nil {
return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err)
}
if r.Tags == nil {
r.Tags = []string{}
}
// func (db *DB) UpdateRecipe(ctx context.Context, id uuid.UUID, r ext.Recipe) (ext.Recipe, error) {
// ingredients, err := json.Marshal(r.Ingredients)
// if err != nil {
// return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err)
// }
// instructions, err := json.Marshal(r.Instructions)
// if err != nil {
// return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err)
// }
// if r.Tags == nil {
// r.Tags = []string{}
// }
_, err = db.pool.Exec(ctx, `
update recipes set
name = $2, cuisine = $3, prep_time_minutes = $4, cook_time_minutes = $5,
servings = $6, ingredients = $7::jsonb, instructions = $8::jsonb,
tags = $9, rating = $10, notes = $11
where id = $1
`, id, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings,
ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes))
if err != nil {
return ext.Recipe{}, fmt.Errorf("update recipe: %w", err)
}
return db.GetRecipe(ctx, id)
}
// _, err = db.pool.Exec(ctx, `
// update recipes set
// name = $2, cuisine = $3, prep_time_minutes = $4, cook_time_minutes = $5,
// servings = $6, ingredients = $7::jsonb, instructions = $8::jsonb,
// tags = $9, rating = $10, notes = $11
// where id = $1
// `, id, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings,
// ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes))
// if err != nil {
// return ext.Recipe{}, fmt.Errorf("update recipe: %w", err)
// }
// return db.GetRecipe(ctx, id)
// }
func (db *DB) CreateMealPlan(ctx context.Context, weekStart time.Time, entries []ext.MealPlanInput) ([]ext.MealPlanEntry, error) {
if _, err := db.pool.Exec(ctx, `delete from meal_plans where week_start = $1`, weekStart); err != nil {
return nil, fmt.Errorf("clear meal plan: %w", err)
}
// func (db *DB) CreateMealPlan(ctx context.Context, weekStart time.Time, entries []ext.MealPlanInput) ([]ext.MealPlanEntry, error) {
// if _, err := db.pool.Exec(ctx, `delete from meal_plans where week_start = $1`, weekStart); err != nil {
// return nil, fmt.Errorf("clear meal plan: %w", err)
// }
var results []ext.MealPlanEntry
for _, e := range entries {
row := db.pool.QueryRow(ctx, `
insert into meal_plans (week_start, day_of_week, meal_type, recipe_id, custom_meal, servings, notes)
values ($1, $2, $3, $4, $5, $6, $7)
returning id, created_at
`, weekStart, e.DayOfWeek, e.MealType, e.RecipeID, nullStr(e.CustomMeal), e.Servings, nullStr(e.Notes))
// var results []ext.MealPlanEntry
// for _, e := range entries {
// row := db.pool.QueryRow(ctx, `
// insert into meal_plans (week_start, day_of_week, meal_type, recipe_id, custom_meal, servings, notes)
// values ($1, $2, $3, $4, $5, $6, $7)
// returning id, created_at
// `, weekStart, e.DayOfWeek, e.MealType, e.RecipeID, nullStr(e.CustomMeal), e.Servings, nullStr(e.Notes))
entry := ext.MealPlanEntry{
WeekStart: weekStart,
DayOfWeek: e.DayOfWeek,
MealType: e.MealType,
RecipeID: e.RecipeID,
CustomMeal: e.CustomMeal,
Servings: e.Servings,
Notes: e.Notes,
}
var model generatedmodels.ModelPublicMealPlans
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
return nil, fmt.Errorf("insert meal plan entry: %w", err)
}
entry.ID = model.ID.UUID()
entry.CreatedAt = model.CreatedAt.Time()
results = append(results, entry)
}
return results, nil
}
// entry := ext.MealPlanEntry{
// WeekStart: weekStart,
// DayOfWeek: e.DayOfWeek,
// MealType: e.MealType,
// RecipeID: e.RecipeID,
// CustomMeal: e.CustomMeal,
// Servings: e.Servings,
// Notes: e.Notes,
// }
// var model generatedmodels.ModelPublicMealPlans
// if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
// return nil, fmt.Errorf("insert meal plan entry: %w", err)
// }
// entry.ID = model.ID.UUID()
// entry.CreatedAt = model.CreatedAt.Time()
// results = append(results, entry)
// }
// return results, nil
// }
func (db *DB) GetMealPlan(ctx context.Context, weekStart time.Time) ([]ext.MealPlanEntry, error) {
rows, err := db.pool.Query(ctx, `
select mp.id, mp.week_start, mp.day_of_week, mp.meal_type, mp.recipe_id, r.name, mp.custom_meal, mp.servings, mp.notes, mp.created_at
from meal_plans mp
left join recipes r on r.id = mp.recipe_id
where mp.week_start = $1
order by
case mp.day_of_week
when 'monday' then 1 when 'tuesday' then 2 when 'wednesday' then 3
when 'thursday' then 4 when 'friday' then 5 when 'saturday' then 6
when 'sunday' then 7 else 8
end,
case mp.meal_type
when 'breakfast' then 1 when 'lunch' then 2 when 'dinner' then 3
when 'snack' then 4 else 5
end
`, weekStart)
if err != nil {
return nil, fmt.Errorf("get meal plan: %w", err)
}
defer rows.Close()
// func (db *DB) GetMealPlan(ctx context.Context, weekStart time.Time) ([]ext.MealPlanEntry, error) {
// rows, err := db.pool.Query(ctx, `
// select mp.id, mp.week_start, mp.day_of_week, mp.meal_type, mp.recipe_id, r.name, mp.custom_meal, mp.servings, mp.notes, mp.created_at
// from meal_plans mp
// left join recipes r on r.id = mp.recipe_id
// where mp.week_start = $1
// order by
// case mp.day_of_week
// when 'monday' then 1 when 'tuesday' then 2 when 'wednesday' then 3
// when 'thursday' then 4 when 'friday' then 5 when 'saturday' then 6
// when 'sunday' then 7 else 8
// end,
// case mp.meal_type
// when 'breakfast' then 1 when 'lunch' then 2 when 'dinner' then 3
// when 'snack' then 4 else 5
// end
// `, weekStart)
// if err != nil {
// return nil, fmt.Errorf("get meal plan: %w", err)
// }
// defer rows.Close()
var entries []ext.MealPlanEntry
for rows.Next() {
var model generatedmodels.ModelPublicMealPlans
var recipeName *string
if err := rows.Scan(&model.ID, &model.WeekStart, &model.DayOfWeek, &model.MealType, &model.RecipeID, &recipeName, &model.CustomMeal, &model.Servings, &model.Notes, &model.CreatedAt); err != nil {
return nil, fmt.Errorf("scan meal plan entry: %w", err)
}
entries = append(entries, mealPlanEntryFromModel(model, strVal(recipeName)))
}
return entries, rows.Err()
}
// var entries []ext.MealPlanEntry
// for rows.Next() {
// var model generatedmodels.ModelPublicMealPlans
// var recipeName *string
// if err := rows.Scan(&model.ID, &model.WeekStart, &model.DayOfWeek, &model.MealType, &model.RecipeID, &recipeName, &model.CustomMeal, &model.Servings, &model.Notes, &model.CreatedAt); err != nil {
// return nil, fmt.Errorf("scan meal plan entry: %w", err)
// }
// entries = append(entries, mealPlanEntryFromModel(model, strVal(recipeName)))
// }
// return entries, rows.Err()
// }
func (db *DB) GenerateShoppingList(ctx context.Context, weekStart time.Time) (ext.ShoppingList, error) {
entries, err := db.GetMealPlan(ctx, weekStart)
if err != nil {
return ext.ShoppingList{}, err
}
// func (db *DB) GenerateShoppingList(ctx context.Context, weekStart time.Time) (ext.ShoppingList, error) {
// entries, err := db.GetMealPlan(ctx, weekStart)
// if err != nil {
// return ext.ShoppingList{}, err
// }
recipeIDs := map[uuid.UUID]bool{}
for _, e := range entries {
if e.RecipeID != nil {
recipeIDs[*e.RecipeID] = true
}
}
// recipeIDs := map[uuid.UUID]bool{}
// for _, e := range entries {
// if e.RecipeID != nil {
// recipeIDs[*e.RecipeID] = true
// }
// }
aggregated := map[string]*ext.ShoppingItem{}
for id := range recipeIDs {
recipe, err := db.GetRecipe(ctx, id)
if err != nil {
continue
}
for _, ing := range recipe.Ingredients {
key := strings.ToLower(ing.Name)
if existing, ok := aggregated[key]; ok {
if ing.Quantity != "" {
existing.Quantity += "+" + ing.Quantity
}
} else {
recipeIDCopy := id
aggregated[key] = &ext.ShoppingItem{
Name: ing.Name,
Quantity: ing.Quantity,
Unit: ing.Unit,
Purchased: false,
RecipeID: &recipeIDCopy,
}
}
}
}
// aggregated := map[string]*ext.ShoppingItem{}
// for id := range recipeIDs {
// recipe, err := db.GetRecipe(ctx, id)
// if err != nil {
// continue
// }
// for _, ing := range recipe.Ingredients {
// key := strings.ToLower(ing.Name)
// if existing, ok := aggregated[key]; ok {
// if ing.Quantity != "" {
// existing.Quantity += "+" + ing.Quantity
// }
// } else {
// recipeIDCopy := id
// aggregated[key] = &ext.ShoppingItem{
// Name: ing.Name,
// Quantity: ing.Quantity,
// Unit: ing.Unit,
// Purchased: false,
// RecipeID: &recipeIDCopy,
// }
// }
// }
// }
items := make([]ext.ShoppingItem, 0, len(aggregated))
for _, item := range aggregated {
items = append(items, *item)
}
// items := make([]ext.ShoppingItem, 0, len(aggregated))
// for _, item := range aggregated {
// items = append(items, *item)
// }
itemsJSON, err := json.Marshal(items)
if err != nil {
return ext.ShoppingList{}, fmt.Errorf("marshal shopping items: %w", err)
}
// itemsJSON, err := json.Marshal(items)
// if err != nil {
// return ext.ShoppingList{}, fmt.Errorf("marshal shopping items: %w", err)
// }
row := db.pool.QueryRow(ctx, `
insert into shopping_lists (week_start, items)
values ($1, $2::jsonb)
on conflict (week_start) do update set items = excluded.items, updated_at = now()
returning id, created_at, updated_at
`, weekStart, itemsJSON)
// row := db.pool.QueryRow(ctx, `
// insert into shopping_lists (week_start, items)
// values ($1, $2::jsonb)
// on conflict (week_start) do update set items = excluded.items, updated_at = now()
// returning id, created_at, updated_at
// `, weekStart, itemsJSON)
var model generatedmodels.ModelPublicShoppingLists
list := ext.ShoppingList{WeekStart: weekStart, Items: items}
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
return ext.ShoppingList{}, fmt.Errorf("upsert shopping list: %w", err)
}
list.ID = model.ID.UUID()
list.CreatedAt = model.CreatedAt.Time()
list.UpdatedAt = model.UpdatedAt.Time()
return list, nil
}
// var model generatedmodels.ModelPublicShoppingLists
// list := ext.ShoppingList{WeekStart: weekStart, Items: items}
// if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
// return ext.ShoppingList{}, fmt.Errorf("upsert shopping list: %w", err)
// }
// list.ID = model.ID.UUID()
// list.CreatedAt = model.CreatedAt.Time()
// list.UpdatedAt = model.UpdatedAt.Time()
// return list, nil
// }
func scanRecipeRow(rows interface{ Scan(...any) error }) (ext.Recipe, error) {
var model generatedmodels.ModelPublicRecipes
var tags []string
if err := rows.Scan(&model.ID, &model.Name, &model.Cuisine, &model.PrepTimeMinutes, &model.CookTimeMinutes, &model.Servings,
&model.Ingredients, &model.Instructions, &tags, &model.Rating, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
return ext.Recipe{}, fmt.Errorf("scan recipe: %w", err)
}
if tags == nil {
tags = []string{}
}
return recipeFromModel(model, tags), nil
}
// func scanRecipeRow(rows interface{ Scan(...any) error }) (ext.Recipe, error) {
// var model generatedmodels.ModelPublicRecipes
// var tags []string
// if err := rows.Scan(&model.ID, &model.Name, &model.Cuisine, &model.PrepTimeMinutes, &model.CookTimeMinutes, &model.Servings,
// &model.Ingredients, &model.Instructions, &tags, &model.Rating, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
// return ext.Recipe{}, fmt.Errorf("scan recipe: %w", err)
// }
// if tags == nil {
// tags = []string{}
// }
// return recipeFromModel(model, tags), nil
// }

View File

@@ -81,343 +81,393 @@ func storedFileFromModel(m generatedmodels.ModelPublicStoredFiles) ext.StoredFil
}
}
func maintenanceTaskFromModel(m generatedmodels.ModelPublicMaintenanceTasks) ext.MaintenanceTask {
var frequencyDays *int
if m.FrequencyDays.Valid {
n := int(m.FrequencyDays.Int64())
frequencyDays = &n
// func maintenanceTaskFromModel(m generatedmodels.ModelPublicMaintenanceTasks) ext.MaintenanceTask {
// var frequencyDays *int
// if m.FrequencyDays.Valid {
// n := int(m.FrequencyDays.Int64())
// frequencyDays = &n
// }
// var lastCompleted *time.Time
// if m.LastCompleted.Valid {
// t := m.LastCompleted.Time()
// lastCompleted = &t
// }
// var nextDue *time.Time
// if m.NextDue.Valid {
// t := m.NextDue.Time()
// nextDue = &t
// }
// return ext.MaintenanceTask{
// ID: m.ID.UUID(),
// Name: m.Name.String(),
// Category: m.Category.String(),
// FrequencyDays: frequencyDays,
// LastCompleted: lastCompleted,
// NextDue: nextDue,
// Priority: m.Priority.String(),
// Notes: m.Notes.String(),
// CreatedAt: m.CreatedAt.Time(),
// UpdatedAt: m.UpdatedAt.Time(),
// }
// }
// func maintenanceLogFromModel(m generatedmodels.ModelPublicMaintenanceLogs) ext.MaintenanceLog {
// var cost *float64
// if m.Cost.Valid {
// v := m.Cost.Float64()
// cost = &v
// }
// return ext.MaintenanceLog{
// ID: m.ID.UUID(),
// TaskID: m.TaskID.UUID(),
// CompletedAt: m.CompletedAt.Time(),
// PerformedBy: m.PerformedBy.String(),
// Cost: cost,
// Notes: m.Notes.String(),
// NextAction: m.NextAction.String(),
// }
// }
// func householdItemFromModel(m generatedmodels.ModelPublicHouseholdItems) ext.HouseholdItem {
// details := map[string]any{}
// if len(m.Details) > 0 {
// if err := json.Unmarshal(m.Details, &details); err != nil {
// details = map[string]any{}
// }
// }
// return ext.HouseholdItem{
// ID: m.ID.UUID(),
// Name: m.Name.String(),
// Category: m.Category.String(),
// Location: m.Location.String(),
// Details: details,
// Notes: m.Notes.String(),
// CreatedAt: m.CreatedAt.Time(),
// UpdatedAt: m.UpdatedAt.Time(),
// }
// }
// func householdVendorFromModel(m generatedmodels.ModelPublicHouseholdVendors) ext.HouseholdVendor {
// var rating *int
// if m.Rating.Valid {
// v := int(m.Rating.Int64())
// rating = &v
// }
// var lastUsed *time.Time
// if m.LastUsed.Valid {
// t := m.LastUsed.Time()
// lastUsed = &t
// }
// return ext.HouseholdVendor{
// ID: m.ID.UUID(),
// Name: m.Name.String(),
// ServiceType: m.ServiceType.String(),
// Phone: m.Phone.String(),
// Email: m.Email.String(),
// Website: m.Website.String(),
// Notes: m.Notes.String(),
// Rating: rating,
// LastUsed: lastUsed,
// CreatedAt: m.CreatedAt.Time(),
// }
// }
// func familyMemberFromModel(m generatedmodels.ModelPublicFamilyMembers) ext.FamilyMember {
// var birthDate *time.Time
// if m.BirthDate.Valid {
// t := m.BirthDate.Time()
// birthDate = &t
// }
// return ext.FamilyMember{
// ID: m.ID.UUID(),
// Name: m.Name.String(),
// Relationship: m.Relationship.String(),
// BirthDate: birthDate,
// Notes: m.Notes.String(),
// CreatedAt: m.CreatedAt.Time(),
// }
// }
// func activityFromModel(m generatedmodels.ModelPublicActivities, memberName string) ext.Activity {
// var familyMemberID *uuid.UUID
// if m.FamilyMemberID.Valid {
// id := m.FamilyMemberID.UUID()
// familyMemberID = &id
// }
// var startDate *time.Time
// if m.StartDate.Valid {
// t := m.StartDate.Time()
// startDate = &t
// }
// var endDate *time.Time
// if m.EndDate.Valid {
// t := m.EndDate.Time()
// endDate = &t
// }
// return ext.Activity{
// ID: m.ID.UUID(),
// FamilyMemberID: familyMemberID,
// MemberName: memberName,
// Title: m.Title.String(),
// ActivityType: m.ActivityType.String(),
// DayOfWeek: m.DayOfWeek.String(),
// StartTime: m.StartTime.String(),
// EndTime: m.EndTime.String(),
// StartDate: startDate,
// EndDate: endDate,
// Location: m.Location.String(),
// Notes: m.Notes.String(),
// CreatedAt: m.CreatedAt.Time(),
// }
// }
// func importantDateFromModel(m generatedmodels.ModelPublicImportantDates, memberName string) ext.ImportantDate {
// var familyMemberID *uuid.UUID
// if m.FamilyMemberID.Valid {
// id := m.FamilyMemberID.UUID()
// familyMemberID = &id
// }
// return ext.ImportantDate{
// ID: m.ID.UUID(),
// FamilyMemberID: familyMemberID,
// MemberName: memberName,
// Title: m.Title.String(),
// DateValue: m.DateValue.Time(),
// RecurringYearly: m.RecurringYearly,
// ReminderDaysBefore: int(m.ReminderDaysBefore),
// Notes: m.Notes.String(),
// CreatedAt: m.CreatedAt.Time(),
// }
// }
// func professionalContactFromModel(m generatedmodels.ModelPublicProfessionalContacts, tags []string) ext.ProfessionalContact {
// var lastContacted *time.Time
// if m.LastContacted.Valid {
// t := m.LastContacted.Time()
// lastContacted = &t
// }
// var followUpDate *time.Time
// if m.FollowUpDate.Valid {
// t := m.FollowUpDate.Time()
// followUpDate = &t
// }
// return ext.ProfessionalContact{
// ID: m.ID.UUID(),
// Name: m.Name.String(),
// Company: m.Company.String(),
// Title: m.Title.String(),
// Email: m.Email.String(),
// Phone: m.Phone.String(),
// LinkedInURL: m.LinkedinURL.String(),
// HowWeMet: m.HowWeMet.String(),
// Tags: tags,
// Notes: m.Notes.String(),
// LastContacted: lastContacted,
// FollowUpDate: followUpDate,
// CreatedAt: m.CreatedAt.Time(),
// UpdatedAt: m.UpdatedAt.Time(),
// }
// }
// func contactInteractionFromModel(m generatedmodels.ModelPublicContactInteractions) ext.ContactInteraction {
// return ext.ContactInteraction{
// ID: m.ID.UUID(),
// ContactID: m.ContactID.UUID(),
// InteractionType: m.InteractionType.String(),
// OccurredAt: m.OccurredAt.Time(),
// Summary: m.Summary.String(),
// FollowUpNeeded: m.FollowUpNeeded,
// FollowUpNotes: m.FollowUpNotes.String(),
// CreatedAt: m.CreatedAt.Time(),
// }
// }
// func opportunityFromModel(m generatedmodels.ModelPublicOpportunities) ext.Opportunity {
// var contactID *uuid.UUID
// if m.ContactID.Valid {
// id := m.ContactID.UUID()
// contactID = &id
// }
// var value *float64
// if m.Value.Valid {
// v := m.Value.Float64()
// value = &v
// }
// var expectedCloseDate *time.Time
// if m.ExpectedCloseDate.Valid {
// t := m.ExpectedCloseDate.Time()
// expectedCloseDate = &t
// }
// return ext.Opportunity{
// ID: m.ID.UUID(),
// ContactID: contactID,
// Title: m.Title.String(),
// Description: m.Description.String(),
// Stage: m.Stage.String(),
// Value: value,
// ExpectedCloseDate: expectedCloseDate,
// Notes: m.Notes.String(),
// CreatedAt: m.CreatedAt.Time(),
// UpdatedAt: m.UpdatedAt.Time(),
// }
// }
// func recipeFromModel(m generatedmodels.ModelPublicRecipes, tags []string) ext.Recipe {
// var prepTimeMinutes *int
// if m.PrepTimeMinutes.Valid {
// v := int(m.PrepTimeMinutes.Int64())
// prepTimeMinutes = &v
// }
// var cookTimeMinutes *int
// if m.CookTimeMinutes.Valid {
// v := int(m.CookTimeMinutes.Int64())
// cookTimeMinutes = &v
// }
// var servings *int
// if m.Servings.Valid {
// v := int(m.Servings.Int64())
// servings = &v
// }
// var rating *int
// if m.Rating.Valid {
// v := int(m.Rating.Int64())
// rating = &v
// }
// recipe := ext.Recipe{
// ID: m.ID.UUID(),
// Name: m.Name.String(),
// Cuisine: m.Cuisine.String(),
// PrepTimeMinutes: prepTimeMinutes,
// CookTimeMinutes: cookTimeMinutes,
// Servings: servings,
// Tags: tags,
// Rating: rating,
// Notes: m.Notes.String(),
// CreatedAt: m.CreatedAt.Time(),
// UpdatedAt: m.UpdatedAt.Time(),
// }
// if err := json.Unmarshal(m.Ingredients, &recipe.Ingredients); err != nil {
// recipe.Ingredients = []ext.Ingredient{}
// }
// if err := json.Unmarshal(m.Instructions, &recipe.Instructions); err != nil {
// recipe.Instructions = []string{}
// }
// return recipe
// }
// func mealPlanEntryFromModel(m generatedmodels.ModelPublicMealPlans, recipeName string) ext.MealPlanEntry {
// var recipeID *uuid.UUID
// if m.RecipeID.Valid {
// id := m.RecipeID.UUID()
// recipeID = &id
// }
// var servings *int
// if m.Servings.Valid {
// v := int(m.Servings.Int64())
// servings = &v
// }
// return ext.MealPlanEntry{
// ID: m.ID.UUID(),
// WeekStart: m.WeekStart.Time(),
// DayOfWeek: m.DayOfWeek.String(),
// MealType: m.MealType.String(),
// RecipeID: recipeID,
// RecipeName: recipeName,
// CustomMeal: m.CustomMeal.String(),
// Servings: servings,
// Notes: m.Notes.String(),
// CreatedAt: m.CreatedAt.Time(),
// }
// }
// func shoppingListFromModel(m generatedmodels.ModelPublicShoppingLists) ext.ShoppingList {
// list := ext.ShoppingList{
// ID: m.ID.UUID(),
// WeekStart: m.WeekStart.Time(),
// Notes: m.Notes.String(),
// CreatedAt: m.CreatedAt.Time(),
// UpdatedAt: m.UpdatedAt.Time(),
// }
// if err := json.Unmarshal(m.Items, &list.Items); err != nil {
// list.Items = []ext.ShoppingItem{}
// }
// return list
// }
func planFromModel(m generatedmodels.ModelPublicPlans, tags []string) ext.Plan {
var projectID *uuid.UUID
if m.ProjectID.Valid {
id := m.ProjectID.UUID()
projectID = &id
}
var lastCompleted *time.Time
if m.LastCompleted.Valid {
t := m.LastCompleted.Time()
lastCompleted = &t
var dueDate *time.Time
if m.DueDate.Valid {
t := m.DueDate.Time()
dueDate = &t
}
var nextDue *time.Time
if m.NextDue.Valid {
t := m.NextDue.Time()
nextDue = &t
var completedAt *time.Time
if m.CompletedAt.Valid {
t := m.CompletedAt.Time()
completedAt = &t
}
return ext.MaintenanceTask{
ID: m.ID.UUID(),
Name: m.Name.String(),
Category: m.Category.String(),
FrequencyDays: frequencyDays,
LastCompleted: lastCompleted,
NextDue: nextDue,
Priority: m.Priority.String(),
Notes: m.Notes.String(),
CreatedAt: m.CreatedAt.Time(),
UpdatedAt: m.UpdatedAt.Time(),
}
}
func maintenanceLogFromModel(m generatedmodels.ModelPublicMaintenanceLogs) ext.MaintenanceLog {
var cost *float64
if m.Cost.Valid {
v := m.Cost.Float64()
cost = &v
var lastReviewedAt *time.Time
if m.LastReviewedAt.Valid {
t := m.LastReviewedAt.Time()
lastReviewedAt = &t
}
return ext.MaintenanceLog{
ID: m.ID.UUID(),
TaskID: m.TaskID.UUID(),
CompletedAt: m.CompletedAt.Time(),
PerformedBy: m.PerformedBy.String(),
Cost: cost,
Notes: m.Notes.String(),
NextAction: m.NextAction.String(),
}
}
func householdItemFromModel(m generatedmodels.ModelPublicHouseholdItems) ext.HouseholdItem {
details := map[string]any{}
if len(m.Details) > 0 {
if err := json.Unmarshal(m.Details, &details); err != nil {
details = map[string]any{}
}
var supersedesPlanID *uuid.UUID
if m.SupersedesPlanID.Valid {
id := m.SupersedesPlanID.UUID()
supersedesPlanID = &id
}
return ext.HouseholdItem{
ID: m.ID.UUID(),
Name: m.Name.String(),
Category: m.Category.String(),
Location: m.Location.String(),
Details: details,
Notes: m.Notes.String(),
CreatedAt: m.CreatedAt.Time(),
UpdatedAt: m.UpdatedAt.Time(),
return ext.Plan{
ID: m.ID.UUID(),
Title: m.Title.String(),
Description: m.Description.String(),
Status: ext.PlanStatus(m.Status.String()),
Priority: ext.PlanPriority(m.Priority.String()),
ProjectID: projectID,
Owner: m.Owner.String(),
DueDate: dueDate,
CompletedAt: completedAt,
ReviewedBy: m.ReviewedBy.String(),
LastReviewedAt: lastReviewedAt,
SupersedesPlanID: supersedesPlanID,
Tags: tags,
CreatedAt: m.CreatedAt.Time(),
UpdatedAt: m.UpdatedAt.Time(),
}
}
func householdVendorFromModel(m generatedmodels.ModelPublicHouseholdVendors) ext.HouseholdVendor {
var rating *int
if m.Rating.Valid {
v := int(m.Rating.Int64())
rating = &v
}
var lastUsed *time.Time
if m.LastUsed.Valid {
t := m.LastUsed.Time()
lastUsed = &t
}
return ext.HouseholdVendor{
ID: m.ID.UUID(),
Name: m.Name.String(),
ServiceType: m.ServiceType.String(),
Phone: m.Phone.String(),
Email: m.Email.String(),
Website: m.Website.String(),
Notes: m.Notes.String(),
Rating: rating,
LastUsed: lastUsed,
CreatedAt: m.CreatedAt.Time(),
}
}
func familyMemberFromModel(m generatedmodels.ModelPublicFamilyMembers) ext.FamilyMember {
var birthDate *time.Time
if m.BirthDate.Valid {
t := m.BirthDate.Time()
birthDate = &t
}
return ext.FamilyMember{
ID: m.ID.UUID(),
Name: m.Name.String(),
Relationship: m.Relationship.String(),
BirthDate: birthDate,
Notes: m.Notes.String(),
CreatedAt: m.CreatedAt.Time(),
}
}
func activityFromModel(m generatedmodels.ModelPublicActivities, memberName string) ext.Activity {
var familyMemberID *uuid.UUID
if m.FamilyMemberID.Valid {
id := m.FamilyMemberID.UUID()
familyMemberID = &id
}
var startDate *time.Time
if m.StartDate.Valid {
t := m.StartDate.Time()
startDate = &t
}
var endDate *time.Time
if m.EndDate.Valid {
t := m.EndDate.Time()
endDate = &t
}
return ext.Activity{
ID: m.ID.UUID(),
FamilyMemberID: familyMemberID,
MemberName: memberName,
Title: m.Title.String(),
ActivityType: m.ActivityType.String(),
DayOfWeek: m.DayOfWeek.String(),
StartTime: m.StartTime.String(),
EndTime: m.EndTime.String(),
StartDate: startDate,
EndDate: endDate,
Location: m.Location.String(),
Notes: m.Notes.String(),
CreatedAt: m.CreatedAt.Time(),
}
}
func importantDateFromModel(m generatedmodels.ModelPublicImportantDates, memberName string) ext.ImportantDate {
var familyMemberID *uuid.UUID
if m.FamilyMemberID.Valid {
id := m.FamilyMemberID.UUID()
familyMemberID = &id
}
return ext.ImportantDate{
ID: m.ID.UUID(),
FamilyMemberID: familyMemberID,
MemberName: memberName,
Title: m.Title.String(),
DateValue: m.DateValue.Time(),
RecurringYearly: m.RecurringYearly,
ReminderDaysBefore: int(m.ReminderDaysBefore.Int64()),
Notes: m.Notes.String(),
CreatedAt: m.CreatedAt.Time(),
}
}
func professionalContactFromModel(m generatedmodels.ModelPublicProfessionalContacts, tags []string) ext.ProfessionalContact {
var lastContacted *time.Time
if m.LastContacted.Valid {
t := m.LastContacted.Time()
lastContacted = &t
}
var followUpDate *time.Time
if m.FollowUpDate.Valid {
t := m.FollowUpDate.Time()
followUpDate = &t
}
return ext.ProfessionalContact{
ID: m.ID.UUID(),
Name: m.Name.String(),
Company: m.Company.String(),
Title: m.Title.String(),
Email: m.Email.String(),
Phone: m.Phone.String(),
LinkedInURL: m.LinkedinURL.String(),
HowWeMet: m.HowWeMet.String(),
Tags: tags,
Notes: m.Notes.String(),
LastContacted: lastContacted,
FollowUpDate: followUpDate,
CreatedAt: m.CreatedAt.Time(),
UpdatedAt: m.UpdatedAt.Time(),
}
}
func contactInteractionFromModel(m generatedmodels.ModelPublicContactInteractions) ext.ContactInteraction {
return ext.ContactInteraction{
ID: m.ID.UUID(),
ContactID: m.ContactID.UUID(),
InteractionType: m.InteractionType.String(),
OccurredAt: m.OccurredAt.Time(),
Summary: m.Summary.String(),
FollowUpNeeded: m.FollowUpNeeded,
FollowUpNotes: m.FollowUpNotes.String(),
CreatedAt: m.CreatedAt.Time(),
}
}
func opportunityFromModel(m generatedmodels.ModelPublicOpportunities) ext.Opportunity {
var contactID *uuid.UUID
if m.ContactID.Valid {
id := m.ContactID.UUID()
contactID = &id
}
var value *float64
if m.Value.Valid {
v := m.Value.Float64()
value = &v
}
var expectedCloseDate *time.Time
if m.ExpectedCloseDate.Valid {
t := m.ExpectedCloseDate.Time()
expectedCloseDate = &t
}
return ext.Opportunity{
ID: m.ID.UUID(),
ContactID: contactID,
Title: m.Title.String(),
Description: m.Description.String(),
Stage: m.Stage.String(),
Value: value,
ExpectedCloseDate: expectedCloseDate,
Notes: m.Notes.String(),
CreatedAt: m.CreatedAt.Time(),
UpdatedAt: m.UpdatedAt.Time(),
}
}
func recipeFromModel(m generatedmodels.ModelPublicRecipes, tags []string) ext.Recipe {
var prepTimeMinutes *int
if m.PrepTimeMinutes.Valid {
v := int(m.PrepTimeMinutes.Int64())
prepTimeMinutes = &v
}
var cookTimeMinutes *int
if m.CookTimeMinutes.Valid {
v := int(m.CookTimeMinutes.Int64())
cookTimeMinutes = &v
}
var servings *int
if m.Servings.Valid {
v := int(m.Servings.Int64())
servings = &v
}
var rating *int
if m.Rating.Valid {
v := int(m.Rating.Int64())
rating = &v
}
recipe := ext.Recipe{
ID: m.ID.UUID(),
Name: m.Name.String(),
Cuisine: m.Cuisine.String(),
PrepTimeMinutes: prepTimeMinutes,
CookTimeMinutes: cookTimeMinutes,
Servings: servings,
Tags: tags,
Rating: rating,
Notes: m.Notes.String(),
CreatedAt: m.CreatedAt.Time(),
UpdatedAt: m.UpdatedAt.Time(),
}
if err := json.Unmarshal(m.Ingredients, &recipe.Ingredients); err != nil {
recipe.Ingredients = []ext.Ingredient{}
}
if err := json.Unmarshal(m.Instructions, &recipe.Instructions); err != nil {
recipe.Instructions = []string{}
}
return recipe
}
func mealPlanEntryFromModel(m generatedmodels.ModelPublicMealPlans, recipeName string) ext.MealPlanEntry {
var recipeID *uuid.UUID
if m.RecipeID.Valid {
id := m.RecipeID.UUID()
recipeID = &id
}
var servings *int
if m.Servings.Valid {
v := int(m.Servings.Int64())
servings = &v
}
return ext.MealPlanEntry{
ID: m.ID.UUID(),
WeekStart: m.WeekStart.Time(),
DayOfWeek: m.DayOfWeek.String(),
MealType: m.MealType.String(),
RecipeID: recipeID,
RecipeName: recipeName,
CustomMeal: m.CustomMeal.String(),
Servings: servings,
Notes: m.Notes.String(),
CreatedAt: m.CreatedAt.Time(),
}
}
func shoppingListFromModel(m generatedmodels.ModelPublicShoppingLists) ext.ShoppingList {
list := ext.ShoppingList{
ID: m.ID.UUID(),
WeekStart: m.WeekStart.Time(),
Notes: m.Notes.String(),
CreatedAt: m.CreatedAt.Time(),
UpdatedAt: m.UpdatedAt.Time(),
}
if err := json.Unmarshal(m.Items, &list.Items); err != nil {
list.Items = []ext.ShoppingItem{}
}
return list
}
func learningFromModel(m generatedmodels.ModelPublicLearnings, tags []string) ext.Learning {
var projectID *uuid.UUID
if m.ProjectID.Valid {

477
internal/store/plans.go Normal file
View File

@@ -0,0 +1,477 @@
package store
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
const planColumns = `
id, title, description, status, priority, project_id, owner, due_date,
completed_at, reviewed_by, last_reviewed_at, supersedes_plan_id, tags::text[], created_at, updated_at`
func (db *DB) CreatePlan(ctx context.Context, plan ext.Plan) (ext.Plan, error) {
row := db.pool.QueryRow(ctx, `
insert into plans (title, description, status, priority, project_id, owner, due_date,
completed_at, reviewed_by, last_reviewed_at, supersedes_plan_id, tags)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
returning`+planColumns,
strings.TrimSpace(plan.Title),
strings.TrimSpace(plan.Description),
string(plan.Status),
string(plan.Priority),
plan.ProjectID,
nullableText(plan.Owner),
plan.DueDate,
plan.CompletedAt,
nullableText(plan.ReviewedBy),
plan.LastReviewedAt,
plan.SupersedesPlanID,
plan.Tags,
)
return scanPlan(row)
}
func (db *DB) GetPlan(ctx context.Context, id uuid.UUID) (ext.Plan, error) {
row := db.pool.QueryRow(ctx, `select`+planColumns+` from plans where id = $1`, id)
plan, err := scanPlan(row)
if err != nil {
if err == pgx.ErrNoRows {
return ext.Plan{}, fmt.Errorf("plan not found: %s", id)
}
return ext.Plan{}, fmt.Errorf("get plan: %w", err)
}
return plan, nil
}
func (db *DB) GetPlanDetail(ctx context.Context, id uuid.UUID) (ext.PlanDetail, error) {
plan, err := db.GetPlan(ctx, id)
if err != nil {
return ext.PlanDetail{}, err
}
dependsOn, err := db.listPlansByQuery(ctx, `
select`+planColumns+`
from plans p
join plan_dependencies pd on pd.depends_on_plan_id = p.id
where pd.plan_id = $1 order by p.title`, id)
if err != nil {
return ext.PlanDetail{}, fmt.Errorf("get plan depends_on: %w", err)
}
blocks, err := db.listPlansByQuery(ctx, `
select`+planColumns+`
from plans p
join plan_dependencies pd on pd.plan_id = p.id
where pd.depends_on_plan_id = $1 order by p.title`, id)
if err != nil {
return ext.PlanDetail{}, fmt.Errorf("get plan blocks: %w", err)
}
related, err := db.listPlansByQuery(ctx, `
select`+planColumns+`
from plans p
where p.id in (
select plan_b_id from plan_related_plans where plan_a_id = $1
union
select plan_a_id from plan_related_plans where plan_b_id = $1
) order by p.title`, id)
if err != nil {
return ext.PlanDetail{}, fmt.Errorf("get plan related: %w", err)
}
skills, err := db.ListPlanSkills(ctx, id)
if err != nil {
return ext.PlanDetail{}, fmt.Errorf("get plan skills: %w", err)
}
guardrails, err := db.ListPlanGuardrails(ctx, id)
if err != nil {
return ext.PlanDetail{}, fmt.Errorf("get plan guardrails: %w", err)
}
return ext.PlanDetail{
Plan: plan,
DependsOn: dependsOn,
Blocks: blocks,
RelatedPlans: related,
Skills: skills,
Guardrails: guardrails,
}, nil
}
func (db *DB) UpdatePlan(ctx context.Context, id uuid.UUID, u ext.PlanUpdate) (ext.Plan, error) {
sets := []string{"updated_at = now()"}
args := []any{}
if u.Title != nil {
args = append(args, strings.TrimSpace(*u.Title))
sets = append(sets, fmt.Sprintf("title = $%d", len(args)))
}
if u.Description != nil {
args = append(args, strings.TrimSpace(*u.Description))
sets = append(sets, fmt.Sprintf("description = $%d", len(args)))
}
if u.Status != nil {
args = append(args, strings.TrimSpace(*u.Status))
sets = append(sets, fmt.Sprintf("status = $%d", len(args)))
}
if u.Priority != nil {
args = append(args, strings.TrimSpace(*u.Priority))
sets = append(sets, fmt.Sprintf("priority = $%d", len(args)))
}
if u.Owner != nil {
args = append(args, nullableText(*u.Owner))
sets = append(sets, fmt.Sprintf("owner = $%d", len(args)))
}
if u.ClearDueDate {
sets = append(sets, "due_date = null")
} else if u.DueDate != nil {
args = append(args, *u.DueDate)
sets = append(sets, fmt.Sprintf("due_date = $%d", len(args)))
}
if u.ClearCompletedAt {
sets = append(sets, "completed_at = null")
} else if u.CompletedAt != nil {
args = append(args, *u.CompletedAt)
sets = append(sets, fmt.Sprintf("completed_at = $%d", len(args)))
}
if u.ReviewedBy != nil {
args = append(args, nullableText(*u.ReviewedBy))
sets = append(sets, fmt.Sprintf("reviewed_by = $%d", len(args)))
}
if u.MarkReviewed {
sets = append(sets, "last_reviewed_at = now()")
}
if u.ClearSupersedesPlanID {
sets = append(sets, "supersedes_plan_id = null")
} else if u.SupersedesPlanID != nil {
args = append(args, *u.SupersedesPlanID)
sets = append(sets, fmt.Sprintf("supersedes_plan_id = $%d", len(args)))
}
if u.Tags != nil {
args = append(args, *u.Tags)
sets = append(sets, fmt.Sprintf("tags = $%d", len(args)))
}
args = append(args, id)
query := fmt.Sprintf(
"update plans set %s where id = $%d returning%s",
strings.Join(sets, ", "), len(args), planColumns,
)
row := db.pool.QueryRow(ctx, query, args...)
plan, err := scanPlan(row)
if err != nil {
if err == pgx.ErrNoRows {
return ext.Plan{}, fmt.Errorf("plan not found: %s", id)
}
return ext.Plan{}, fmt.Errorf("update plan: %w", err)
}
return plan, nil
}
func (db *DB) DeletePlan(ctx context.Context, id uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `delete from plans where id = $1`, id)
if err != nil {
return fmt.Errorf("delete plan: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("plan not found")
}
return nil
}
func (db *DB) ListPlans(ctx context.Context, filter ext.PlanFilter) ([]ext.Plan, error) {
args := make([]any, 0, 8)
conditions := make([]string, 0, 8)
if filter.ProjectID != nil {
args = append(args, *filter.ProjectID)
conditions = append(conditions, fmt.Sprintf("project_id = $%d", len(args)))
}
if v := strings.TrimSpace(filter.Status); v != "" {
args = append(args, v)
conditions = append(conditions, fmt.Sprintf("status = $%d", len(args)))
}
if v := strings.TrimSpace(filter.Priority); v != "" {
args = append(args, v)
conditions = append(conditions, fmt.Sprintf("priority = $%d", len(args)))
}
if v := strings.TrimSpace(filter.Owner); v != "" {
args = append(args, v)
conditions = append(conditions, fmt.Sprintf("owner = $%d", len(args)))
}
if v := strings.TrimSpace(filter.Tag); v != "" {
args = append(args, v)
conditions = append(conditions, fmt.Sprintf("$%d = any(tags)", len(args)))
}
if v := strings.TrimSpace(filter.Query); v != "" {
args = append(args, v)
conditions = append(conditions, fmt.Sprintf(
"to_tsvector('simple', title || ' ' || coalesce(description, '')) @@ websearch_to_tsquery('simple', $%d)", len(args)))
}
query := "select" + planColumns + " from plans"
if len(conditions) > 0 {
query += " where " + strings.Join(conditions, " and ")
}
query += " order by updated_at desc"
if filter.Limit > 0 {
args = append(args, filter.Limit)
query += fmt.Sprintf(" limit $%d", len(args))
}
return db.listPlansByQuery(ctx, query, args...)
}
// Dependencies
func (db *DB) AddPlanDependency(ctx context.Context, planID, dependsOnPlanID uuid.UUID) error {
_, err := db.pool.Exec(ctx, `
insert into plan_dependencies (plan_id, depends_on_plan_id)
values ($1, $2)
on conflict do nothing
`, planID, dependsOnPlanID)
if err != nil {
return fmt.Errorf("add plan dependency: %w", err)
}
return nil
}
func (db *DB) RemovePlanDependency(ctx context.Context, planID, dependsOnPlanID uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `
delete from plan_dependencies where plan_id = $1 and depends_on_plan_id = $2
`, planID, dependsOnPlanID)
if err != nil {
return fmt.Errorf("remove plan dependency: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("plan dependency not found")
}
return nil
}
// Related Plans
func (db *DB) AddRelatedPlan(ctx context.Context, planAID, planBID uuid.UUID) error {
a, b := canonicalPlanPair(planAID, planBID)
_, err := db.pool.Exec(ctx, `
insert into plan_related_plans (plan_a_id, plan_b_id)
values ($1, $2)
on conflict do nothing
`, a, b)
if err != nil {
return fmt.Errorf("add related plan: %w", err)
}
return nil
}
func (db *DB) RemoveRelatedPlan(ctx context.Context, planAID, planBID uuid.UUID) error {
a, b := canonicalPlanPair(planAID, planBID)
tag, err := db.pool.Exec(ctx, `
delete from plan_related_plans where plan_a_id = $1 and plan_b_id = $2
`, a, b)
if err != nil {
return fmt.Errorf("remove related plan: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("related plan link not found")
}
return nil
}
// Plan Skills
func (db *DB) AddPlanSkill(ctx context.Context, planID, skillID uuid.UUID) error {
_, err := db.pool.Exec(ctx, `
insert into plan_skills (plan_id, skill_id) values ($1, $2) on conflict do nothing
`, planID, skillID)
if err != nil {
return fmt.Errorf("add plan skill: %w", err)
}
return nil
}
func (db *DB) RemovePlanSkill(ctx context.Context, planID, skillID uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `
delete from plan_skills where plan_id = $1 and skill_id = $2
`, planID, skillID)
if err != nil {
return fmt.Errorf("remove plan skill: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("plan skill link not found")
}
return nil
}
func (db *DB) ListPlanSkills(ctx context.Context, planID uuid.UUID) ([]ext.AgentSkill, error) {
rows, err := db.pool.Query(ctx, `
select s.id, s.name, s.description, s.content, s.tags::text[], s.created_at, s.updated_at
from agent_skills s
join plan_skills ps on ps.skill_id = s.id
where ps.plan_id = $1
order by s.name
`, planID)
if err != nil {
return nil, fmt.Errorf("list plan skills: %w", err)
}
defer rows.Close()
var skills []ext.AgentSkill
for rows.Next() {
var model generatedmodels.ModelPublicAgentSkills
var tags []string
if err := rows.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &tags, &model.CreatedAt, &model.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan plan skill: %w", err)
}
s := ext.AgentSkill{
ID: model.ID.UUID(),
Name: model.Name.String(),
Description: model.Description.String(),
Content: model.Content.String(),
Tags: tags,
CreatedAt: model.CreatedAt.Time(),
UpdatedAt: model.UpdatedAt.Time(),
}
if s.Tags == nil {
s.Tags = []string{}
}
skills = append(skills, s)
}
return skills, rows.Err()
}
// Plan Guardrails
func (db *DB) AddPlanGuardrail(ctx context.Context, planID, guardrailID uuid.UUID) error {
_, err := db.pool.Exec(ctx, `
insert into plan_guardrails (plan_id, guardrail_id) values ($1, $2) on conflict do nothing
`, planID, guardrailID)
if err != nil {
return fmt.Errorf("add plan guardrail: %w", err)
}
return nil
}
func (db *DB) RemovePlanGuardrail(ctx context.Context, planID, guardrailID uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `
delete from plan_guardrails where plan_id = $1 and guardrail_id = $2
`, planID, guardrailID)
if err != nil {
return fmt.Errorf("remove plan guardrail: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("plan guardrail link not found")
}
return nil
}
func (db *DB) ListPlanGuardrails(ctx context.Context, planID uuid.UUID) ([]ext.AgentGuardrail, error) {
rows, err := db.pool.Query(ctx, `
select g.id, g.name, g.description, g.content, g.severity, g.tags::text[], g.created_at, g.updated_at
from agent_guardrails g
join plan_guardrails pg on pg.guardrail_id = g.id
where pg.plan_id = $1
order by g.name
`, planID)
if err != nil {
return nil, fmt.Errorf("list plan guardrails: %w", err)
}
defer rows.Close()
var guardrails []ext.AgentGuardrail
for rows.Next() {
var model generatedmodels.ModelPublicAgentGuardrails
var tags []string
if err := rows.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &model.Severity, &tags, &model.CreatedAt, &model.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan plan guardrail: %w", err)
}
g := ext.AgentGuardrail{
ID: model.ID.UUID(),
Name: model.Name.String(),
Description: model.Description.String(),
Content: model.Content.String(),
Severity: model.Severity.String(),
Tags: tags,
CreatedAt: model.CreatedAt.Time(),
UpdatedAt: model.UpdatedAt.Time(),
}
if g.Tags == nil {
g.Tags = []string{}
}
guardrails = append(guardrails, g)
}
return guardrails, rows.Err()
}
// helpers
type planScanner interface {
Scan(dest ...any) error
}
func scanPlan(row planScanner) (ext.Plan, error) {
var model generatedmodels.ModelPublicPlans
var tags []string
err := row.Scan(
&model.ID,
&model.Title,
&model.Description,
&model.Status,
&model.Priority,
&model.ProjectID,
&model.Owner,
&model.DueDate,
&model.CompletedAt,
&model.ReviewedBy,
&model.LastReviewedAt,
&model.SupersedesPlanID,
&tags,
&model.CreatedAt,
&model.UpdatedAt,
)
if err != nil {
return ext.Plan{}, err
}
if tags == nil {
tags = []string{}
}
return planFromModel(model, tags), nil
}
func (db *DB) listPlansByQuery(ctx context.Context, query string, args ...any) ([]ext.Plan, error) {
rows, err := db.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
plans := make([]ext.Plan, 0)
for rows.Next() {
plan, err := scanPlan(rows)
if err != nil {
return nil, fmt.Errorf("scan plan: %w", err)
}
plans = append(plans, plan)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate plans: %w", err)
}
return plans, nil
}
// canonicalPlanPair ensures the smaller UUID is always plan_a_id to prevent duplicates.
func canonicalPlanPair(a, b uuid.UUID) (uuid.UUID, uuid.UUID) {
if strings.Compare(a.String(), b.String()) <= 0 {
return a, b
}
return b, a
}

View File

@@ -1,212 +1,212 @@
package tools
import (
"context"
"strings"
"time"
// import (
// "context"
// "strings"
// "time"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
// "github.com/google/uuid"
// "github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/store"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
// "git.warky.dev/wdevs/amcs/internal/store"
// ext "git.warky.dev/wdevs/amcs/internal/types"
// )
type CalendarTool struct {
store *store.DB
}
// type CalendarTool struct {
// store *store.DB
// }
func NewCalendarTool(db *store.DB) *CalendarTool {
return &CalendarTool{store: db}
}
// func NewCalendarTool(db *store.DB) *CalendarTool {
// return &CalendarTool{store: db}
// }
// add_family_member
// // add_family_member
type AddFamilyMemberInput struct {
Name string `json:"name" jsonschema:"person's name"`
Relationship string `json:"relationship,omitempty" jsonschema:"e.g. self, spouse, child, parent"`
BirthDate *time.Time `json:"birth_date,omitempty"`
Notes string `json:"notes,omitempty"`
}
// type AddFamilyMemberInput struct {
// Name string `json:"name" jsonschema:"person's name"`
// Relationship string `json:"relationship,omitempty" jsonschema:"e.g. self, spouse, child, parent"`
// BirthDate *time.Time `json:"birth_date,omitempty"`
// Notes string `json:"notes,omitempty"`
// }
type AddFamilyMemberOutput struct {
Member ext.FamilyMember `json:"member"`
}
// type AddFamilyMemberOutput struct {
// Member ext.FamilyMember `json:"member"`
// }
func (t *CalendarTool) AddMember(ctx context.Context, _ *mcp.CallToolRequest, in AddFamilyMemberInput) (*mcp.CallToolResult, AddFamilyMemberOutput, error) {
if strings.TrimSpace(in.Name) == "" {
return nil, AddFamilyMemberOutput{}, errRequiredField("name")
}
member, err := t.store.AddFamilyMember(ctx, ext.FamilyMember{
Name: strings.TrimSpace(in.Name),
Relationship: strings.TrimSpace(in.Relationship),
BirthDate: in.BirthDate,
Notes: strings.TrimSpace(in.Notes),
})
if err != nil {
return nil, AddFamilyMemberOutput{}, err
}
return nil, AddFamilyMemberOutput{Member: member}, nil
}
// func (t *CalendarTool) AddMember(ctx context.Context, _ *mcp.CallToolRequest, in AddFamilyMemberInput) (*mcp.CallToolResult, AddFamilyMemberOutput, error) {
// if strings.TrimSpace(in.Name) == "" {
// return nil, AddFamilyMemberOutput{}, errRequiredField("name")
// }
// member, err := t.store.AddFamilyMember(ctx, ext.FamilyMember{
// Name: strings.TrimSpace(in.Name),
// Relationship: strings.TrimSpace(in.Relationship),
// BirthDate: in.BirthDate,
// Notes: strings.TrimSpace(in.Notes),
// })
// if err != nil {
// return nil, AddFamilyMemberOutput{}, err
// }
// return nil, AddFamilyMemberOutput{Member: member}, nil
// }
// list_family_members
// // list_family_members
type ListFamilyMembersInput struct{}
// type ListFamilyMembersInput struct{}
type ListFamilyMembersOutput struct {
Members []ext.FamilyMember `json:"members"`
}
// type ListFamilyMembersOutput struct {
// Members []ext.FamilyMember `json:"members"`
// }
func (t *CalendarTool) ListMembers(ctx context.Context, _ *mcp.CallToolRequest, _ ListFamilyMembersInput) (*mcp.CallToolResult, ListFamilyMembersOutput, error) {
members, err := t.store.ListFamilyMembers(ctx)
if err != nil {
return nil, ListFamilyMembersOutput{}, err
}
if members == nil {
members = []ext.FamilyMember{}
}
return nil, ListFamilyMembersOutput{Members: members}, nil
}
// func (t *CalendarTool) ListMembers(ctx context.Context, _ *mcp.CallToolRequest, _ ListFamilyMembersInput) (*mcp.CallToolResult, ListFamilyMembersOutput, error) {
// members, err := t.store.ListFamilyMembers(ctx)
// if err != nil {
// return nil, ListFamilyMembersOutput{}, err
// }
// if members == nil {
// members = []ext.FamilyMember{}
// }
// return nil, ListFamilyMembersOutput{Members: members}, nil
// }
// add_activity
// // add_activity
type AddActivityInput struct {
Title string `json:"title" jsonschema:"activity title"`
ActivityType string `json:"activity_type,omitempty" jsonschema:"e.g. sports, medical, school, social"`
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty" jsonschema:"leave empty for whole-family activities"`
DayOfWeek string `json:"day_of_week,omitempty" jsonschema:"for recurring: monday, tuesday, etc."`
StartTime string `json:"start_time,omitempty" jsonschema:"HH:MM format"`
EndTime string `json:"end_time,omitempty" jsonschema:"HH:MM format"`
StartDate *time.Time `json:"start_date,omitempty"`
EndDate *time.Time `json:"end_date,omitempty" jsonschema:"for recurring activities, when they end"`
Location string `json:"location,omitempty"`
Notes string `json:"notes,omitempty"`
}
// type AddActivityInput struct {
// Title string `json:"title" jsonschema:"activity title"`
// ActivityType string `json:"activity_type,omitempty" jsonschema:"e.g. sports, medical, school, social"`
// FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty" jsonschema:"leave empty for whole-family activities"`
// DayOfWeek string `json:"day_of_week,omitempty" jsonschema:"for recurring: monday, tuesday, etc."`
// StartTime string `json:"start_time,omitempty" jsonschema:"HH:MM format"`
// EndTime string `json:"end_time,omitempty" jsonschema:"HH:MM format"`
// StartDate *time.Time `json:"start_date,omitempty"`
// EndDate *time.Time `json:"end_date,omitempty" jsonschema:"for recurring activities, when they end"`
// Location string `json:"location,omitempty"`
// Notes string `json:"notes,omitempty"`
// }
type AddActivityOutput struct {
Activity ext.Activity `json:"activity"`
}
// type AddActivityOutput struct {
// Activity ext.Activity `json:"activity"`
// }
func (t *CalendarTool) AddActivity(ctx context.Context, _ *mcp.CallToolRequest, in AddActivityInput) (*mcp.CallToolResult, AddActivityOutput, error) {
if strings.TrimSpace(in.Title) == "" {
return nil, AddActivityOutput{}, errRequiredField("title")
}
activity, err := t.store.AddActivity(ctx, ext.Activity{
FamilyMemberID: in.FamilyMemberID,
Title: strings.TrimSpace(in.Title),
ActivityType: strings.TrimSpace(in.ActivityType),
DayOfWeek: strings.ToLower(strings.TrimSpace(in.DayOfWeek)),
StartTime: strings.TrimSpace(in.StartTime),
EndTime: strings.TrimSpace(in.EndTime),
StartDate: in.StartDate,
EndDate: in.EndDate,
Location: strings.TrimSpace(in.Location),
Notes: strings.TrimSpace(in.Notes),
})
if err != nil {
return nil, AddActivityOutput{}, err
}
return nil, AddActivityOutput{Activity: activity}, nil
}
// func (t *CalendarTool) AddActivity(ctx context.Context, _ *mcp.CallToolRequest, in AddActivityInput) (*mcp.CallToolResult, AddActivityOutput, error) {
// if strings.TrimSpace(in.Title) == "" {
// return nil, AddActivityOutput{}, errRequiredField("title")
// }
// activity, err := t.store.AddActivity(ctx, ext.Activity{
// FamilyMemberID: in.FamilyMemberID,
// Title: strings.TrimSpace(in.Title),
// ActivityType: strings.TrimSpace(in.ActivityType),
// DayOfWeek: strings.ToLower(strings.TrimSpace(in.DayOfWeek)),
// StartTime: strings.TrimSpace(in.StartTime),
// EndTime: strings.TrimSpace(in.EndTime),
// StartDate: in.StartDate,
// EndDate: in.EndDate,
// Location: strings.TrimSpace(in.Location),
// Notes: strings.TrimSpace(in.Notes),
// })
// if err != nil {
// return nil, AddActivityOutput{}, err
// }
// return nil, AddActivityOutput{Activity: activity}, nil
// }
// get_week_schedule
// // get_week_schedule
type GetWeekScheduleInput struct {
WeekStart time.Time `json:"week_start" jsonschema:"start of the week (Monday) to retrieve"`
}
// type GetWeekScheduleInput struct {
// WeekStart time.Time `json:"week_start" jsonschema:"start of the week (Monday) to retrieve"`
// }
type GetWeekScheduleOutput struct {
Activities []ext.Activity `json:"activities"`
}
// type GetWeekScheduleOutput struct {
// Activities []ext.Activity `json:"activities"`
// }
func (t *CalendarTool) GetWeekSchedule(ctx context.Context, _ *mcp.CallToolRequest, in GetWeekScheduleInput) (*mcp.CallToolResult, GetWeekScheduleOutput, error) {
activities, err := t.store.GetWeekSchedule(ctx, in.WeekStart)
if err != nil {
return nil, GetWeekScheduleOutput{}, err
}
if activities == nil {
activities = []ext.Activity{}
}
return nil, GetWeekScheduleOutput{Activities: activities}, nil
}
// func (t *CalendarTool) GetWeekSchedule(ctx context.Context, _ *mcp.CallToolRequest, in GetWeekScheduleInput) (*mcp.CallToolResult, GetWeekScheduleOutput, error) {
// activities, err := t.store.GetWeekSchedule(ctx, in.WeekStart)
// if err != nil {
// return nil, GetWeekScheduleOutput{}, err
// }
// if activities == nil {
// activities = []ext.Activity{}
// }
// return nil, GetWeekScheduleOutput{Activities: activities}, nil
// }
// search_activities
// // search_activities
type SearchActivitiesInput struct {
Query string `json:"query,omitempty" jsonschema:"search text matching title or notes"`
ActivityType string `json:"activity_type,omitempty" jsonschema:"filter by type"`
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty" jsonschema:"filter by family member"`
}
// type SearchActivitiesInput struct {
// Query string `json:"query,omitempty" jsonschema:"search text matching title or notes"`
// ActivityType string `json:"activity_type,omitempty" jsonschema:"filter by type"`
// FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty" jsonschema:"filter by family member"`
// }
type SearchActivitiesOutput struct {
Activities []ext.Activity `json:"activities"`
}
// type SearchActivitiesOutput struct {
// Activities []ext.Activity `json:"activities"`
// }
func (t *CalendarTool) SearchActivities(ctx context.Context, _ *mcp.CallToolRequest, in SearchActivitiesInput) (*mcp.CallToolResult, SearchActivitiesOutput, error) {
activities, err := t.store.SearchActivities(ctx, in.Query, in.ActivityType, in.FamilyMemberID)
if err != nil {
return nil, SearchActivitiesOutput{}, err
}
if activities == nil {
activities = []ext.Activity{}
}
return nil, SearchActivitiesOutput{Activities: activities}, nil
}
// func (t *CalendarTool) SearchActivities(ctx context.Context, _ *mcp.CallToolRequest, in SearchActivitiesInput) (*mcp.CallToolResult, SearchActivitiesOutput, error) {
// activities, err := t.store.SearchActivities(ctx, in.Query, in.ActivityType, in.FamilyMemberID)
// if err != nil {
// return nil, SearchActivitiesOutput{}, err
// }
// if activities == nil {
// activities = []ext.Activity{}
// }
// return nil, SearchActivitiesOutput{Activities: activities}, nil
// }
// add_important_date
// // add_important_date
type AddImportantDateInput struct {
Title string `json:"title" jsonschema:"description of the date"`
DateValue time.Time `json:"date_value" jsonschema:"the date"`
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty"`
RecurringYearly bool `json:"recurring_yearly,omitempty" jsonschema:"if true, reminds every year"`
ReminderDaysBefore int `json:"reminder_days_before,omitempty" jsonschema:"how many days before to remind (default: 7)"`
Notes string `json:"notes,omitempty"`
}
// type AddImportantDateInput struct {
// Title string `json:"title" jsonschema:"description of the date"`
// DateValue time.Time `json:"date_value" jsonschema:"the date"`
// FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty"`
// RecurringYearly bool `json:"recurring_yearly,omitempty" jsonschema:"if true, reminds every year"`
// ReminderDaysBefore int `json:"reminder_days_before,omitempty" jsonschema:"how many days before to remind (default: 7)"`
// Notes string `json:"notes,omitempty"`
// }
type AddImportantDateOutput struct {
Date ext.ImportantDate `json:"date"`
}
// type AddImportantDateOutput struct {
// Date ext.ImportantDate `json:"date"`
// }
func (t *CalendarTool) AddImportantDate(ctx context.Context, _ *mcp.CallToolRequest, in AddImportantDateInput) (*mcp.CallToolResult, AddImportantDateOutput, error) {
if strings.TrimSpace(in.Title) == "" {
return nil, AddImportantDateOutput{}, errRequiredField("title")
}
reminder := in.ReminderDaysBefore
if reminder <= 0 {
reminder = 7
}
d, err := t.store.AddImportantDate(ctx, ext.ImportantDate{
FamilyMemberID: in.FamilyMemberID,
Title: strings.TrimSpace(in.Title),
DateValue: in.DateValue,
RecurringYearly: in.RecurringYearly,
ReminderDaysBefore: reminder,
Notes: strings.TrimSpace(in.Notes),
})
if err != nil {
return nil, AddImportantDateOutput{}, err
}
return nil, AddImportantDateOutput{Date: d}, nil
}
// func (t *CalendarTool) AddImportantDate(ctx context.Context, _ *mcp.CallToolRequest, in AddImportantDateInput) (*mcp.CallToolResult, AddImportantDateOutput, error) {
// if strings.TrimSpace(in.Title) == "" {
// return nil, AddImportantDateOutput{}, errRequiredField("title")
// }
// reminder := in.ReminderDaysBefore
// if reminder <= 0 {
// reminder = 7
// }
// d, err := t.store.AddImportantDate(ctx, ext.ImportantDate{
// FamilyMemberID: in.FamilyMemberID,
// Title: strings.TrimSpace(in.Title),
// DateValue: in.DateValue,
// RecurringYearly: in.RecurringYearly,
// ReminderDaysBefore: reminder,
// Notes: strings.TrimSpace(in.Notes),
// })
// if err != nil {
// return nil, AddImportantDateOutput{}, err
// }
// return nil, AddImportantDateOutput{Date: d}, nil
// }
// get_upcoming_dates
// // get_upcoming_dates
type GetUpcomingDatesInput struct {
DaysAhead int `json:"days_ahead,omitempty" jsonschema:"how many days to look ahead (default: 30)"`
}
// type GetUpcomingDatesInput struct {
// DaysAhead int `json:"days_ahead,omitempty" jsonschema:"how many days to look ahead (default: 30)"`
// }
type GetUpcomingDatesOutput struct {
Dates []ext.ImportantDate `json:"dates"`
}
// type GetUpcomingDatesOutput struct {
// Dates []ext.ImportantDate `json:"dates"`
// }
func (t *CalendarTool) GetUpcomingDates(ctx context.Context, _ *mcp.CallToolRequest, in GetUpcomingDatesInput) (*mcp.CallToolResult, GetUpcomingDatesOutput, error) {
dates, err := t.store.GetUpcomingDates(ctx, in.DaysAhead)
if err != nil {
return nil, GetUpcomingDatesOutput{}, err
}
if dates == nil {
dates = []ext.ImportantDate{}
}
return nil, GetUpcomingDatesOutput{Dates: dates}, nil
}
// func (t *CalendarTool) GetUpcomingDates(ctx context.Context, _ *mcp.CallToolRequest, in GetUpcomingDatesInput) (*mcp.CallToolResult, GetUpcomingDatesOutput, error) {
// dates, err := t.store.GetUpcomingDates(ctx, in.DaysAhead)
// if err != nil {
// return nil, GetUpcomingDatesOutput{}, err
// }
// if dates == nil {
// dates = []ext.ImportantDate{}
// }
// return nil, GetUpcomingDatesOutput{Dates: dates}, nil
// }

View File

@@ -1,240 +1,240 @@
package tools
import (
"context"
"errors"
"fmt"
"strings"
"time"
// import (
// "context"
// "errors"
// "fmt"
// "strings"
// "time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/modelcontextprotocol/go-sdk/mcp"
// "github.com/google/uuid"
// "github.com/jackc/pgx/v5"
// "github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/store"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
// "git.warky.dev/wdevs/amcs/internal/store"
// ext "git.warky.dev/wdevs/amcs/internal/types"
// )
type CRMTool struct {
store *store.DB
}
// type CRMTool struct {
// store *store.DB
// }
func NewCRMTool(db *store.DB) *CRMTool {
return &CRMTool{store: db}
}
// func NewCRMTool(db *store.DB) *CRMTool {
// return &CRMTool{store: db}
// }
// add_professional_contact
// // add_professional_contact
type AddContactInput struct {
Name string `json:"name" jsonschema:"contact's full name"`
Company string `json:"company,omitempty"`
Title string `json:"title,omitempty" jsonschema:"job title"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
LinkedInURL string `json:"linkedin_url,omitempty"`
HowWeMet string `json:"how_we_met,omitempty"`
Tags []string `json:"tags,omitempty"`
Notes string `json:"notes,omitempty"`
FollowUpDate *time.Time `json:"follow_up_date,omitempty"`
}
// type AddContactInput struct {
// Name string `json:"name" jsonschema:"contact's full name"`
// Company string `json:"company,omitempty"`
// Title string `json:"title,omitempty" jsonschema:"job title"`
// Email string `json:"email,omitempty"`
// Phone string `json:"phone,omitempty"`
// LinkedInURL string `json:"linkedin_url,omitempty"`
// HowWeMet string `json:"how_we_met,omitempty"`
// Tags []string `json:"tags,omitempty"`
// Notes string `json:"notes,omitempty"`
// FollowUpDate *time.Time `json:"follow_up_date,omitempty"`
// }
type AddContactOutput struct {
Contact ext.ProfessionalContact `json:"contact"`
}
// type AddContactOutput struct {
// Contact ext.ProfessionalContact `json:"contact"`
// }
func (t *CRMTool) AddContact(ctx context.Context, _ *mcp.CallToolRequest, in AddContactInput) (*mcp.CallToolResult, AddContactOutput, error) {
if strings.TrimSpace(in.Name) == "" {
return nil, AddContactOutput{}, errRequiredField("name")
}
if in.Tags == nil {
in.Tags = []string{}
}
contact, err := t.store.AddProfessionalContact(ctx, ext.ProfessionalContact{
Name: strings.TrimSpace(in.Name),
Company: strings.TrimSpace(in.Company),
Title: strings.TrimSpace(in.Title),
Email: strings.TrimSpace(in.Email),
Phone: strings.TrimSpace(in.Phone),
LinkedInURL: strings.TrimSpace(in.LinkedInURL),
HowWeMet: strings.TrimSpace(in.HowWeMet),
Tags: in.Tags,
Notes: strings.TrimSpace(in.Notes),
FollowUpDate: in.FollowUpDate,
})
if err != nil {
return nil, AddContactOutput{}, err
}
return nil, AddContactOutput{Contact: contact}, nil
}
// func (t *CRMTool) AddContact(ctx context.Context, _ *mcp.CallToolRequest, in AddContactInput) (*mcp.CallToolResult, AddContactOutput, error) {
// if strings.TrimSpace(in.Name) == "" {
// return nil, AddContactOutput{}, errRequiredField("name")
// }
// if in.Tags == nil {
// in.Tags = []string{}
// }
// contact, err := t.store.AddProfessionalContact(ctx, ext.ProfessionalContact{
// Name: strings.TrimSpace(in.Name),
// Company: strings.TrimSpace(in.Company),
// Title: strings.TrimSpace(in.Title),
// Email: strings.TrimSpace(in.Email),
// Phone: strings.TrimSpace(in.Phone),
// LinkedInURL: strings.TrimSpace(in.LinkedInURL),
// HowWeMet: strings.TrimSpace(in.HowWeMet),
// Tags: in.Tags,
// Notes: strings.TrimSpace(in.Notes),
// FollowUpDate: in.FollowUpDate,
// })
// if err != nil {
// return nil, AddContactOutput{}, err
// }
// return nil, AddContactOutput{Contact: contact}, nil
// }
// search_contacts
// // search_contacts
type SearchContactsInput struct {
Query string `json:"query,omitempty" jsonschema:"search text matching name, company, title, or notes"`
Tags []string `json:"tags,omitempty" jsonschema:"filter by tags (all must match)"`
}
// type SearchContactsInput struct {
// Query string `json:"query,omitempty" jsonschema:"search text matching name, company, title, or notes"`
// Tags []string `json:"tags,omitempty" jsonschema:"filter by tags (all must match)"`
// }
type SearchContactsOutput struct {
Contacts []ext.ProfessionalContact `json:"contacts"`
}
// type SearchContactsOutput struct {
// Contacts []ext.ProfessionalContact `json:"contacts"`
// }
func (t *CRMTool) SearchContacts(ctx context.Context, _ *mcp.CallToolRequest, in SearchContactsInput) (*mcp.CallToolResult, SearchContactsOutput, error) {
contacts, err := t.store.SearchContacts(ctx, in.Query, in.Tags)
if err != nil {
return nil, SearchContactsOutput{}, err
}
if contacts == nil {
contacts = []ext.ProfessionalContact{}
}
return nil, SearchContactsOutput{Contacts: contacts}, nil
}
// func (t *CRMTool) SearchContacts(ctx context.Context, _ *mcp.CallToolRequest, in SearchContactsInput) (*mcp.CallToolResult, SearchContactsOutput, error) {
// contacts, err := t.store.SearchContacts(ctx, in.Query, in.Tags)
// if err != nil {
// return nil, SearchContactsOutput{}, err
// }
// if contacts == nil {
// contacts = []ext.ProfessionalContact{}
// }
// return nil, SearchContactsOutput{Contacts: contacts}, nil
// }
// log_interaction
// // log_interaction
type LogInteractionInput struct {
ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
InteractionType string `json:"interaction_type" jsonschema:"one of: meeting, email, call, coffee, event, linkedin, other"`
OccurredAt *time.Time `json:"occurred_at,omitempty" jsonschema:"when it happened (defaults to now)"`
Summary string `json:"summary" jsonschema:"summary of the interaction"`
FollowUpNeeded bool `json:"follow_up_needed,omitempty"`
FollowUpNotes string `json:"follow_up_notes,omitempty"`
}
// type LogInteractionInput struct {
// ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
// InteractionType string `json:"interaction_type" jsonschema:"one of: meeting, email, call, coffee, event, linkedin, other"`
// OccurredAt *time.Time `json:"occurred_at,omitempty" jsonschema:"when it happened (defaults to now)"`
// Summary string `json:"summary" jsonschema:"summary of the interaction"`
// FollowUpNeeded bool `json:"follow_up_needed,omitempty"`
// FollowUpNotes string `json:"follow_up_notes,omitempty"`
// }
type LogInteractionOutput struct {
Interaction ext.ContactInteraction `json:"interaction"`
}
// type LogInteractionOutput struct {
// Interaction ext.ContactInteraction `json:"interaction"`
// }
func (t *CRMTool) LogInteraction(ctx context.Context, _ *mcp.CallToolRequest, in LogInteractionInput) (*mcp.CallToolResult, LogInteractionOutput, error) {
if strings.TrimSpace(in.Summary) == "" {
return nil, LogInteractionOutput{}, errRequiredField("summary")
}
occurredAt := time.Now()
if in.OccurredAt != nil {
occurredAt = *in.OccurredAt
}
interaction, err := t.store.LogInteraction(ctx, ext.ContactInteraction{
ContactID: in.ContactID,
InteractionType: in.InteractionType,
OccurredAt: occurredAt,
Summary: strings.TrimSpace(in.Summary),
FollowUpNeeded: in.FollowUpNeeded,
FollowUpNotes: strings.TrimSpace(in.FollowUpNotes),
})
if err != nil {
return nil, LogInteractionOutput{}, err
}
return nil, LogInteractionOutput{Interaction: interaction}, nil
}
// func (t *CRMTool) LogInteraction(ctx context.Context, _ *mcp.CallToolRequest, in LogInteractionInput) (*mcp.CallToolResult, LogInteractionOutput, error) {
// if strings.TrimSpace(in.Summary) == "" {
// return nil, LogInteractionOutput{}, errRequiredField("summary")
// }
// occurredAt := time.Now()
// if in.OccurredAt != nil {
// occurredAt = *in.OccurredAt
// }
// interaction, err := t.store.LogInteraction(ctx, ext.ContactInteraction{
// ContactID: in.ContactID,
// InteractionType: in.InteractionType,
// OccurredAt: occurredAt,
// Summary: strings.TrimSpace(in.Summary),
// FollowUpNeeded: in.FollowUpNeeded,
// FollowUpNotes: strings.TrimSpace(in.FollowUpNotes),
// })
// if err != nil {
// return nil, LogInteractionOutput{}, err
// }
// return nil, LogInteractionOutput{Interaction: interaction}, nil
// }
// get_contact_history
// // get_contact_history
type GetContactHistoryInput struct {
ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
}
// type GetContactHistoryInput struct {
// ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
// }
type GetContactHistoryOutput struct {
History ext.ContactHistory `json:"history"`
}
// type GetContactHistoryOutput struct {
// History ext.ContactHistory `json:"history"`
// }
func (t *CRMTool) GetHistory(ctx context.Context, _ *mcp.CallToolRequest, in GetContactHistoryInput) (*mcp.CallToolResult, GetContactHistoryOutput, error) {
history, err := t.store.GetContactHistory(ctx, in.ContactID)
if err != nil {
return nil, GetContactHistoryOutput{}, err
}
return nil, GetContactHistoryOutput{History: history}, nil
}
// func (t *CRMTool) GetHistory(ctx context.Context, _ *mcp.CallToolRequest, in GetContactHistoryInput) (*mcp.CallToolResult, GetContactHistoryOutput, error) {
// history, err := t.store.GetContactHistory(ctx, in.ContactID)
// if err != nil {
// return nil, GetContactHistoryOutput{}, err
// }
// return nil, GetContactHistoryOutput{History: history}, nil
// }
// create_opportunity
// // create_opportunity
type CreateOpportunityInput struct {
ContactID *uuid.UUID `json:"contact_id,omitempty"`
Title string `json:"title" jsonschema:"opportunity title"`
Description string `json:"description,omitempty"`
Stage string `json:"stage,omitempty" jsonschema:"one of: identified, in_conversation, proposal, negotiation, won, lost (default: identified)"`
Value *float64 `json:"value,omitempty" jsonschema:"monetary value"`
ExpectedCloseDate *time.Time `json:"expected_close_date,omitempty"`
Notes string `json:"notes,omitempty"`
}
// type CreateOpportunityInput struct {
// ContactID *uuid.UUID `json:"contact_id,omitempty"`
// Title string `json:"title" jsonschema:"opportunity title"`
// Description string `json:"description,omitempty"`
// Stage string `json:"stage,omitempty" jsonschema:"one of: identified, in_conversation, proposal, negotiation, won, lost (default: identified)"`
// Value *float64 `json:"value,omitempty" jsonschema:"monetary value"`
// ExpectedCloseDate *time.Time `json:"expected_close_date,omitempty"`
// Notes string `json:"notes,omitempty"`
// }
type CreateOpportunityOutput struct {
Opportunity ext.Opportunity `json:"opportunity"`
}
// type CreateOpportunityOutput struct {
// Opportunity ext.Opportunity `json:"opportunity"`
// }
func (t *CRMTool) CreateOpportunity(ctx context.Context, _ *mcp.CallToolRequest, in CreateOpportunityInput) (*mcp.CallToolResult, CreateOpportunityOutput, error) {
if strings.TrimSpace(in.Title) == "" {
return nil, CreateOpportunityOutput{}, errRequiredField("title")
}
stage := strings.TrimSpace(in.Stage)
if stage == "" {
stage = "identified"
}
opp, err := t.store.CreateOpportunity(ctx, ext.Opportunity{
ContactID: in.ContactID,
Title: strings.TrimSpace(in.Title),
Description: strings.TrimSpace(in.Description),
Stage: stage,
Value: in.Value,
ExpectedCloseDate: in.ExpectedCloseDate,
Notes: strings.TrimSpace(in.Notes),
})
if err != nil {
return nil, CreateOpportunityOutput{}, err
}
return nil, CreateOpportunityOutput{Opportunity: opp}, nil
}
// func (t *CRMTool) CreateOpportunity(ctx context.Context, _ *mcp.CallToolRequest, in CreateOpportunityInput) (*mcp.CallToolResult, CreateOpportunityOutput, error) {
// if strings.TrimSpace(in.Title) == "" {
// return nil, CreateOpportunityOutput{}, errRequiredField("title")
// }
// stage := strings.TrimSpace(in.Stage)
// if stage == "" {
// stage = "identified"
// }
// opp, err := t.store.CreateOpportunity(ctx, ext.Opportunity{
// ContactID: in.ContactID,
// Title: strings.TrimSpace(in.Title),
// Description: strings.TrimSpace(in.Description),
// Stage: stage,
// Value: in.Value,
// ExpectedCloseDate: in.ExpectedCloseDate,
// Notes: strings.TrimSpace(in.Notes),
// })
// if err != nil {
// return nil, CreateOpportunityOutput{}, err
// }
// return nil, CreateOpportunityOutput{Opportunity: opp}, nil
// }
// get_follow_ups_due
// // get_follow_ups_due
type GetFollowUpsDueInput struct {
DaysAhead int `json:"days_ahead,omitempty" jsonschema:"look ahead window in days (default: 7)"`
}
// type GetFollowUpsDueInput struct {
// DaysAhead int `json:"days_ahead,omitempty" jsonschema:"look ahead window in days (default: 7)"`
// }
type GetFollowUpsDueOutput struct {
Contacts []ext.ProfessionalContact `json:"contacts"`
}
// type GetFollowUpsDueOutput struct {
// Contacts []ext.ProfessionalContact `json:"contacts"`
// }
func (t *CRMTool) GetFollowUpsDue(ctx context.Context, _ *mcp.CallToolRequest, in GetFollowUpsDueInput) (*mcp.CallToolResult, GetFollowUpsDueOutput, error) {
contacts, err := t.store.GetFollowUpsDue(ctx, in.DaysAhead)
if err != nil {
return nil, GetFollowUpsDueOutput{}, err
}
if contacts == nil {
contacts = []ext.ProfessionalContact{}
}
return nil, GetFollowUpsDueOutput{Contacts: contacts}, nil
}
// func (t *CRMTool) GetFollowUpsDue(ctx context.Context, _ *mcp.CallToolRequest, in GetFollowUpsDueInput) (*mcp.CallToolResult, GetFollowUpsDueOutput, error) {
// contacts, err := t.store.GetFollowUpsDue(ctx, in.DaysAhead)
// if err != nil {
// return nil, GetFollowUpsDueOutput{}, err
// }
// if contacts == nil {
// contacts = []ext.ProfessionalContact{}
// }
// return nil, GetFollowUpsDueOutput{Contacts: contacts}, nil
// }
// link_thought_to_contact
// // link_thought_to_contact
type LinkThoughtToContactInput struct {
ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
ThoughtID uuid.UUID `json:"thought_id" jsonschema:"id of the thought to link"`
}
// type LinkThoughtToContactInput struct {
// ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
// ThoughtID uuid.UUID `json:"thought_id" jsonschema:"id of the thought to link"`
// }
type LinkThoughtToContactOutput struct {
Contact ext.ProfessionalContact `json:"contact"`
}
// type LinkThoughtToContactOutput struct {
// Contact ext.ProfessionalContact `json:"contact"`
// }
func (t *CRMTool) LinkThought(ctx context.Context, _ *mcp.CallToolRequest, in LinkThoughtToContactInput) (*mcp.CallToolResult, LinkThoughtToContactOutput, error) {
thought, err := t.store.GetThought(ctx, in.ThoughtID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, LinkThoughtToContactOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID.String())
}
return nil, LinkThoughtToContactOutput{}, err
}
// func (t *CRMTool) LinkThought(ctx context.Context, _ *mcp.CallToolRequest, in LinkThoughtToContactInput) (*mcp.CallToolResult, LinkThoughtToContactOutput, error) {
// thought, err := t.store.GetThought(ctx, in.ThoughtID)
// if err != nil {
// if errors.Is(err, pgx.ErrNoRows) {
// return nil, LinkThoughtToContactOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID.String())
// }
// return nil, LinkThoughtToContactOutput{}, err
// }
appendText := fmt.Sprintf("\n\n[Linked thought %s]: %s", thought.ID, thought.Content)
if err := t.store.AppendThoughtToContactNotes(ctx, in.ContactID, appendText); err != nil {
return nil, LinkThoughtToContactOutput{}, err
}
// appendText := fmt.Sprintf("\n\n[Linked thought %s]: %s", thought.ID, thought.Content)
// if err := t.store.AppendThoughtToContactNotes(ctx, in.ContactID, appendText); err != nil {
// return nil, LinkThoughtToContactOutput{}, err
// }
contact, err := t.store.GetContact(ctx, in.ContactID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, LinkThoughtToContactOutput{}, errEntityNotFound("contact", "contact_id", in.ContactID.String())
}
return nil, LinkThoughtToContactOutput{}, err
}
return nil, LinkThoughtToContactOutput{Contact: contact}, nil
}
// contact, err := t.store.GetContact(ctx, in.ContactID)
// if err != nil {
// if errors.Is(err, pgx.ErrNoRows) {
// return nil, LinkThoughtToContactOutput{}, errEntityNotFound("contact", "contact_id", in.ContactID.String())
// }
// return nil, LinkThoughtToContactOutput{}, err
// }
// return nil, LinkThoughtToContactOutput{Contact: contact}, nil
// }

View File

@@ -1,151 +1,151 @@
package tools
import (
"context"
"strings"
// import (
// "context"
// "strings"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
// "github.com/google/uuid"
// "github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/store"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
// "git.warky.dev/wdevs/amcs/internal/store"
// ext "git.warky.dev/wdevs/amcs/internal/types"
// )
type HouseholdTool struct {
store *store.DB
}
// type HouseholdTool struct {
// store *store.DB
// }
func NewHouseholdTool(db *store.DB) *HouseholdTool {
return &HouseholdTool{store: db}
}
// func NewHouseholdTool(db *store.DB) *HouseholdTool {
// return &HouseholdTool{store: db}
// }
// add_household_item
// // add_household_item
type AddHouseholdItemInput struct {
Name string `json:"name" jsonschema:"name of the item"`
Category string `json:"category,omitempty" jsonschema:"category (e.g. paint, appliance, measurement, document)"`
Location string `json:"location,omitempty" jsonschema:"where in the home this item is"`
Details map[string]any `json:"details,omitempty" jsonschema:"flexible metadata (model numbers, colors, specs, etc.)"`
Notes string `json:"notes,omitempty"`
}
// type AddHouseholdItemInput struct {
// Name string `json:"name" jsonschema:"name of the item"`
// Category string `json:"category,omitempty" jsonschema:"category (e.g. paint, appliance, measurement, document)"`
// Location string `json:"location,omitempty" jsonschema:"where in the home this item is"`
// Details map[string]any `json:"details,omitempty" jsonschema:"flexible metadata (model numbers, colors, specs, etc.)"`
// Notes string `json:"notes,omitempty"`
// }
type AddHouseholdItemOutput struct {
Item ext.HouseholdItem `json:"item"`
}
// type AddHouseholdItemOutput struct {
// Item ext.HouseholdItem `json:"item"`
// }
func (t *HouseholdTool) AddItem(ctx context.Context, _ *mcp.CallToolRequest, in AddHouseholdItemInput) (*mcp.CallToolResult, AddHouseholdItemOutput, error) {
if strings.TrimSpace(in.Name) == "" {
return nil, AddHouseholdItemOutput{}, errRequiredField("name")
}
if in.Details == nil {
in.Details = map[string]any{}
}
item, err := t.store.AddHouseholdItem(ctx, ext.HouseholdItem{
Name: strings.TrimSpace(in.Name),
Category: strings.TrimSpace(in.Category),
Location: strings.TrimSpace(in.Location),
Details: in.Details,
Notes: strings.TrimSpace(in.Notes),
})
if err != nil {
return nil, AddHouseholdItemOutput{}, err
}
return nil, AddHouseholdItemOutput{Item: item}, nil
}
// func (t *HouseholdTool) AddItem(ctx context.Context, _ *mcp.CallToolRequest, in AddHouseholdItemInput) (*mcp.CallToolResult, AddHouseholdItemOutput, error) {
// if strings.TrimSpace(in.Name) == "" {
// return nil, AddHouseholdItemOutput{}, errRequiredField("name")
// }
// if in.Details == nil {
// in.Details = map[string]any{}
// }
// item, err := t.store.AddHouseholdItem(ctx, ext.HouseholdItem{
// Name: strings.TrimSpace(in.Name),
// Category: strings.TrimSpace(in.Category),
// Location: strings.TrimSpace(in.Location),
// Details: in.Details,
// Notes: strings.TrimSpace(in.Notes),
// })
// if err != nil {
// return nil, AddHouseholdItemOutput{}, err
// }
// return nil, AddHouseholdItemOutput{Item: item}, nil
// }
// search_household_items
// // search_household_items
type SearchHouseholdItemsInput struct {
Query string `json:"query,omitempty" jsonschema:"search text matching name or notes"`
Category string `json:"category,omitempty" jsonschema:"filter by category"`
Location string `json:"location,omitempty" jsonschema:"filter by location"`
}
// type SearchHouseholdItemsInput struct {
// Query string `json:"query,omitempty" jsonschema:"search text matching name or notes"`
// Category string `json:"category,omitempty" jsonschema:"filter by category"`
// Location string `json:"location,omitempty" jsonschema:"filter by location"`
// }
type SearchHouseholdItemsOutput struct {
Items []ext.HouseholdItem `json:"items"`
}
// type SearchHouseholdItemsOutput struct {
// Items []ext.HouseholdItem `json:"items"`
// }
func (t *HouseholdTool) SearchItems(ctx context.Context, _ *mcp.CallToolRequest, in SearchHouseholdItemsInput) (*mcp.CallToolResult, SearchHouseholdItemsOutput, error) {
items, err := t.store.SearchHouseholdItems(ctx, in.Query, in.Category, in.Location)
if err != nil {
return nil, SearchHouseholdItemsOutput{}, err
}
if items == nil {
items = []ext.HouseholdItem{}
}
return nil, SearchHouseholdItemsOutput{Items: items}, nil
}
// func (t *HouseholdTool) SearchItems(ctx context.Context, _ *mcp.CallToolRequest, in SearchHouseholdItemsInput) (*mcp.CallToolResult, SearchHouseholdItemsOutput, error) {
// items, err := t.store.SearchHouseholdItems(ctx, in.Query, in.Category, in.Location)
// if err != nil {
// return nil, SearchHouseholdItemsOutput{}, err
// }
// if items == nil {
// items = []ext.HouseholdItem{}
// }
// return nil, SearchHouseholdItemsOutput{Items: items}, nil
// }
// get_household_item
// // get_household_item
type GetHouseholdItemInput struct {
ID uuid.UUID `json:"id" jsonschema:"item id"`
}
// type GetHouseholdItemInput struct {
// ID uuid.UUID `json:"id" jsonschema:"item id"`
// }
type GetHouseholdItemOutput struct {
Item ext.HouseholdItem `json:"item"`
}
// type GetHouseholdItemOutput struct {
// Item ext.HouseholdItem `json:"item"`
// }
func (t *HouseholdTool) GetItem(ctx context.Context, _ *mcp.CallToolRequest, in GetHouseholdItemInput) (*mcp.CallToolResult, GetHouseholdItemOutput, error) {
item, err := t.store.GetHouseholdItem(ctx, in.ID)
if err != nil {
return nil, GetHouseholdItemOutput{}, err
}
return nil, GetHouseholdItemOutput{Item: item}, nil
}
// func (t *HouseholdTool) GetItem(ctx context.Context, _ *mcp.CallToolRequest, in GetHouseholdItemInput) (*mcp.CallToolResult, GetHouseholdItemOutput, error) {
// item, err := t.store.GetHouseholdItem(ctx, in.ID)
// if err != nil {
// return nil, GetHouseholdItemOutput{}, err
// }
// return nil, GetHouseholdItemOutput{Item: item}, nil
// }
// add_vendor
// // add_vendor
type AddVendorInput struct {
Name string `json:"name" jsonschema:"vendor name"`
ServiceType string `json:"service_type,omitempty" jsonschema:"type of service (e.g. plumber, electrician, landscaper)"`
Phone string `json:"phone,omitempty"`
Email string `json:"email,omitempty"`
Website string `json:"website,omitempty"`
Notes string `json:"notes,omitempty"`
Rating *int `json:"rating,omitempty" jsonschema:"1-5 rating"`
}
// type AddVendorInput struct {
// Name string `json:"name" jsonschema:"vendor name"`
// ServiceType string `json:"service_type,omitempty" jsonschema:"type of service (e.g. plumber, electrician, landscaper)"`
// Phone string `json:"phone,omitempty"`
// Email string `json:"email,omitempty"`
// Website string `json:"website,omitempty"`
// Notes string `json:"notes,omitempty"`
// Rating *int `json:"rating,omitempty" jsonschema:"1-5 rating"`
// }
type AddVendorOutput struct {
Vendor ext.HouseholdVendor `json:"vendor"`
}
// type AddVendorOutput struct {
// Vendor ext.HouseholdVendor `json:"vendor"`
// }
func (t *HouseholdTool) AddVendor(ctx context.Context, _ *mcp.CallToolRequest, in AddVendorInput) (*mcp.CallToolResult, AddVendorOutput, error) {
if strings.TrimSpace(in.Name) == "" {
return nil, AddVendorOutput{}, errRequiredField("name")
}
vendor, err := t.store.AddVendor(ctx, ext.HouseholdVendor{
Name: strings.TrimSpace(in.Name),
ServiceType: strings.TrimSpace(in.ServiceType),
Phone: strings.TrimSpace(in.Phone),
Email: strings.TrimSpace(in.Email),
Website: strings.TrimSpace(in.Website),
Notes: strings.TrimSpace(in.Notes),
Rating: in.Rating,
})
if err != nil {
return nil, AddVendorOutput{}, err
}
return nil, AddVendorOutput{Vendor: vendor}, nil
}
// func (t *HouseholdTool) AddVendor(ctx context.Context, _ *mcp.CallToolRequest, in AddVendorInput) (*mcp.CallToolResult, AddVendorOutput, error) {
// if strings.TrimSpace(in.Name) == "" {
// return nil, AddVendorOutput{}, errRequiredField("name")
// }
// vendor, err := t.store.AddVendor(ctx, ext.HouseholdVendor{
// Name: strings.TrimSpace(in.Name),
// ServiceType: strings.TrimSpace(in.ServiceType),
// Phone: strings.TrimSpace(in.Phone),
// Email: strings.TrimSpace(in.Email),
// Website: strings.TrimSpace(in.Website),
// Notes: strings.TrimSpace(in.Notes),
// Rating: in.Rating,
// })
// if err != nil {
// return nil, AddVendorOutput{}, err
// }
// return nil, AddVendorOutput{Vendor: vendor}, nil
// }
// list_vendors
// // list_vendors
type ListVendorsInput struct {
ServiceType string `json:"service_type,omitempty" jsonschema:"filter by service type"`
}
// type ListVendorsInput struct {
// ServiceType string `json:"service_type,omitempty" jsonschema:"filter by service type"`
// }
type ListVendorsOutput struct {
Vendors []ext.HouseholdVendor `json:"vendors"`
}
// type ListVendorsOutput struct {
// Vendors []ext.HouseholdVendor `json:"vendors"`
// }
func (t *HouseholdTool) ListVendors(ctx context.Context, _ *mcp.CallToolRequest, in ListVendorsInput) (*mcp.CallToolResult, ListVendorsOutput, error) {
vendors, err := t.store.ListVendors(ctx, in.ServiceType)
if err != nil {
return nil, ListVendorsOutput{}, err
}
if vendors == nil {
vendors = []ext.HouseholdVendor{}
}
return nil, ListVendorsOutput{Vendors: vendors}, nil
}
// func (t *HouseholdTool) ListVendors(ctx context.Context, _ *mcp.CallToolRequest, in ListVendorsInput) (*mcp.CallToolResult, ListVendorsOutput, error) {
// vendors, err := t.store.ListVendors(ctx, in.ServiceType)
// if err != nil {
// return nil, ListVendorsOutput{}, err
// }
// if vendors == nil {
// vendors = []ext.HouseholdVendor{}
// }
// return nil, ListVendorsOutput{Vendors: vendors}, nil
// }

View File

@@ -1,137 +1,137 @@
package tools
import (
"context"
"strings"
"time"
// import (
// "context"
// "strings"
// "time"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
// "github.com/google/uuid"
// "github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/store"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
// "git.warky.dev/wdevs/amcs/internal/store"
// ext "git.warky.dev/wdevs/amcs/internal/types"
// )
type MaintenanceTool struct {
store *store.DB
}
// type MaintenanceTool struct {
// store *store.DB
// }
func NewMaintenanceTool(db *store.DB) *MaintenanceTool {
return &MaintenanceTool{store: db}
}
// func NewMaintenanceTool(db *store.DB) *MaintenanceTool {
// return &MaintenanceTool{store: db}
// }
// add_maintenance_task
// // add_maintenance_task
type AddMaintenanceTaskInput struct {
Name string `json:"name" jsonschema:"task name"`
Category string `json:"category,omitempty" jsonschema:"e.g. hvac, plumbing, exterior, appliance, landscaping"`
FrequencyDays *int `json:"frequency_days,omitempty" jsonschema:"recurrence interval in days; omit for one-time tasks"`
NextDue *time.Time `json:"next_due,omitempty" jsonschema:"when the task is next due"`
Priority string `json:"priority,omitempty" jsonschema:"low, medium, high, or urgent (default: medium)"`
Notes string `json:"notes,omitempty"`
}
// type AddMaintenanceTaskInput struct {
// Name string `json:"name" jsonschema:"task name"`
// Category string `json:"category,omitempty" jsonschema:"e.g. hvac, plumbing, exterior, appliance, landscaping"`
// FrequencyDays *int `json:"frequency_days,omitempty" jsonschema:"recurrence interval in days; omit for one-time tasks"`
// NextDue *time.Time `json:"next_due,omitempty" jsonschema:"when the task is next due"`
// Priority string `json:"priority,omitempty" jsonschema:"low, medium, high, or urgent (default: medium)"`
// Notes string `json:"notes,omitempty"`
// }
type AddMaintenanceTaskOutput struct {
Task ext.MaintenanceTask `json:"task"`
}
// type AddMaintenanceTaskOutput struct {
// Task ext.MaintenanceTask `json:"task"`
// }
func (t *MaintenanceTool) AddTask(ctx context.Context, _ *mcp.CallToolRequest, in AddMaintenanceTaskInput) (*mcp.CallToolResult, AddMaintenanceTaskOutput, error) {
if strings.TrimSpace(in.Name) == "" {
return nil, AddMaintenanceTaskOutput{}, errRequiredField("name")
}
priority := strings.TrimSpace(in.Priority)
if priority == "" {
priority = "medium"
}
task, err := t.store.AddMaintenanceTask(ctx, ext.MaintenanceTask{
Name: strings.TrimSpace(in.Name),
Category: strings.TrimSpace(in.Category),
FrequencyDays: in.FrequencyDays,
NextDue: in.NextDue,
Priority: priority,
Notes: strings.TrimSpace(in.Notes),
})
if err != nil {
return nil, AddMaintenanceTaskOutput{}, err
}
return nil, AddMaintenanceTaskOutput{Task: task}, nil
}
// func (t *MaintenanceTool) AddTask(ctx context.Context, _ *mcp.CallToolRequest, in AddMaintenanceTaskInput) (*mcp.CallToolResult, AddMaintenanceTaskOutput, error) {
// if strings.TrimSpace(in.Name) == "" {
// return nil, AddMaintenanceTaskOutput{}, errRequiredField("name")
// }
// priority := strings.TrimSpace(in.Priority)
// if priority == "" {
// priority = "medium"
// }
// task, err := t.store.AddMaintenanceTask(ctx, ext.MaintenanceTask{
// Name: strings.TrimSpace(in.Name),
// Category: strings.TrimSpace(in.Category),
// FrequencyDays: in.FrequencyDays,
// NextDue: in.NextDue,
// Priority: priority,
// Notes: strings.TrimSpace(in.Notes),
// })
// if err != nil {
// return nil, AddMaintenanceTaskOutput{}, err
// }
// return nil, AddMaintenanceTaskOutput{Task: task}, nil
// }
// log_maintenance
// // log_maintenance
type LogMaintenanceInput struct {
TaskID uuid.UUID `json:"task_id" jsonschema:"id of the maintenance task"`
CompletedAt *time.Time `json:"completed_at,omitempty" jsonschema:"when the work was done (defaults to now)"`
PerformedBy string `json:"performed_by,omitempty" jsonschema:"who did the work (self, vendor name, etc.)"`
Cost *float64 `json:"cost,omitempty" jsonschema:"cost of the work"`
Notes string `json:"notes,omitempty"`
NextAction string `json:"next_action,omitempty" jsonschema:"recommended follow-up"`
}
// type LogMaintenanceInput struct {
// TaskID uuid.UUID `json:"task_id" jsonschema:"id of the maintenance task"`
// CompletedAt *time.Time `json:"completed_at,omitempty" jsonschema:"when the work was done (defaults to now)"`
// PerformedBy string `json:"performed_by,omitempty" jsonschema:"who did the work (self, vendor name, etc.)"`
// Cost *float64 `json:"cost,omitempty" jsonschema:"cost of the work"`
// Notes string `json:"notes,omitempty"`
// NextAction string `json:"next_action,omitempty" jsonschema:"recommended follow-up"`
// }
type LogMaintenanceOutput struct {
Log ext.MaintenanceLog `json:"log"`
}
// type LogMaintenanceOutput struct {
// Log ext.MaintenanceLog `json:"log"`
// }
func (t *MaintenanceTool) LogWork(ctx context.Context, _ *mcp.CallToolRequest, in LogMaintenanceInput) (*mcp.CallToolResult, LogMaintenanceOutput, error) {
completedAt := time.Now()
if in.CompletedAt != nil {
completedAt = *in.CompletedAt
}
log, err := t.store.LogMaintenance(ctx, ext.MaintenanceLog{
TaskID: in.TaskID,
CompletedAt: completedAt,
PerformedBy: strings.TrimSpace(in.PerformedBy),
Cost: in.Cost,
Notes: strings.TrimSpace(in.Notes),
NextAction: strings.TrimSpace(in.NextAction),
})
if err != nil {
return nil, LogMaintenanceOutput{}, err
}
return nil, LogMaintenanceOutput{Log: log}, nil
}
// func (t *MaintenanceTool) LogWork(ctx context.Context, _ *mcp.CallToolRequest, in LogMaintenanceInput) (*mcp.CallToolResult, LogMaintenanceOutput, error) {
// completedAt := time.Now()
// if in.CompletedAt != nil {
// completedAt = *in.CompletedAt
// }
// log, err := t.store.LogMaintenance(ctx, ext.MaintenanceLog{
// TaskID: in.TaskID,
// CompletedAt: completedAt,
// PerformedBy: strings.TrimSpace(in.PerformedBy),
// Cost: in.Cost,
// Notes: strings.TrimSpace(in.Notes),
// NextAction: strings.TrimSpace(in.NextAction),
// })
// if err != nil {
// return nil, LogMaintenanceOutput{}, err
// }
// return nil, LogMaintenanceOutput{Log: log}, nil
// }
// get_upcoming_maintenance
// // get_upcoming_maintenance
type GetUpcomingMaintenanceInput struct {
DaysAhead int `json:"days_ahead,omitempty" jsonschema:"how many days to look ahead (default: 30)"`
}
// type GetUpcomingMaintenanceInput struct {
// DaysAhead int `json:"days_ahead,omitempty" jsonschema:"how many days to look ahead (default: 30)"`
// }
type GetUpcomingMaintenanceOutput struct {
Tasks []ext.MaintenanceTask `json:"tasks"`
}
// type GetUpcomingMaintenanceOutput struct {
// Tasks []ext.MaintenanceTask `json:"tasks"`
// }
func (t *MaintenanceTool) GetUpcoming(ctx context.Context, _ *mcp.CallToolRequest, in GetUpcomingMaintenanceInput) (*mcp.CallToolResult, GetUpcomingMaintenanceOutput, error) {
tasks, err := t.store.GetUpcomingMaintenance(ctx, in.DaysAhead)
if err != nil {
return nil, GetUpcomingMaintenanceOutput{}, err
}
if tasks == nil {
tasks = []ext.MaintenanceTask{}
}
return nil, GetUpcomingMaintenanceOutput{Tasks: tasks}, nil
}
// func (t *MaintenanceTool) GetUpcoming(ctx context.Context, _ *mcp.CallToolRequest, in GetUpcomingMaintenanceInput) (*mcp.CallToolResult, GetUpcomingMaintenanceOutput, error) {
// tasks, err := t.store.GetUpcomingMaintenance(ctx, in.DaysAhead)
// if err != nil {
// return nil, GetUpcomingMaintenanceOutput{}, err
// }
// if tasks == nil {
// tasks = []ext.MaintenanceTask{}
// }
// return nil, GetUpcomingMaintenanceOutput{Tasks: tasks}, nil
// }
// search_maintenance_history
// // search_maintenance_history
type SearchMaintenanceHistoryInput struct {
Query string `json:"query,omitempty" jsonschema:"search text matching task name or notes"`
Category string `json:"category,omitempty" jsonschema:"filter by task category"`
Start *time.Time `json:"start,omitempty" jsonschema:"filter logs completed on or after this date"`
End *time.Time `json:"end,omitempty" jsonschema:"filter logs completed on or before this date"`
}
// type SearchMaintenanceHistoryInput struct {
// Query string `json:"query,omitempty" jsonschema:"search text matching task name or notes"`
// Category string `json:"category,omitempty" jsonschema:"filter by task category"`
// Start *time.Time `json:"start,omitempty" jsonschema:"filter logs completed on or after this date"`
// End *time.Time `json:"end,omitempty" jsonschema:"filter logs completed on or before this date"`
// }
type SearchMaintenanceHistoryOutput struct {
Logs []ext.MaintenanceLogWithTask `json:"logs"`
}
// type SearchMaintenanceHistoryOutput struct {
// Logs []ext.MaintenanceLogWithTask `json:"logs"`
// }
func (t *MaintenanceTool) SearchHistory(ctx context.Context, _ *mcp.CallToolRequest, in SearchMaintenanceHistoryInput) (*mcp.CallToolResult, SearchMaintenanceHistoryOutput, error) {
logs, err := t.store.SearchMaintenanceHistory(ctx, in.Query, in.Category, in.Start, in.End)
if err != nil {
return nil, SearchMaintenanceHistoryOutput{}, err
}
if logs == nil {
logs = []ext.MaintenanceLogWithTask{}
}
return nil, SearchMaintenanceHistoryOutput{Logs: logs}, nil
}
// func (t *MaintenanceTool) SearchHistory(ctx context.Context, _ *mcp.CallToolRequest, in SearchMaintenanceHistoryInput) (*mcp.CallToolResult, SearchMaintenanceHistoryOutput, error) {
// logs, err := t.store.SearchMaintenanceHistory(ctx, in.Query, in.Category, in.Start, in.End)
// if err != nil {
// return nil, SearchMaintenanceHistoryOutput{}, err
// }
// if logs == nil {
// logs = []ext.MaintenanceLogWithTask{}
// }
// return nil, SearchMaintenanceHistoryOutput{Logs: logs}, nil
// }

View File

@@ -1,210 +1,210 @@
package tools
import (
"context"
"strings"
"time"
// import (
// "context"
// "strings"
// "time"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
// "github.com/google/uuid"
// "github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/store"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
// "git.warky.dev/wdevs/amcs/internal/store"
// ext "git.warky.dev/wdevs/amcs/internal/types"
// )
type MealsTool struct {
store *store.DB
}
// type MealsTool struct {
// store *store.DB
// }
func NewMealsTool(db *store.DB) *MealsTool {
return &MealsTool{store: db}
}
// func NewMealsTool(db *store.DB) *MealsTool {
// return &MealsTool{store: db}
// }
// add_recipe
// // add_recipe
type AddRecipeInput struct {
Name string `json:"name" jsonschema:"recipe name"`
Cuisine string `json:"cuisine,omitempty"`
PrepTimeMinutes *int `json:"prep_time_minutes,omitempty"`
CookTimeMinutes *int `json:"cook_time_minutes,omitempty"`
Servings *int `json:"servings,omitempty"`
Ingredients []ext.Ingredient `json:"ingredients,omitempty" jsonschema:"list of ingredients with name, quantity, unit"`
Instructions []string `json:"instructions,omitempty" jsonschema:"ordered list of steps"`
Tags []string `json:"tags,omitempty"`
Rating *int `json:"rating,omitempty" jsonschema:"1-5 rating"`
Notes string `json:"notes,omitempty"`
}
// type AddRecipeInput struct {
// Name string `json:"name" jsonschema:"recipe name"`
// Cuisine string `json:"cuisine,omitempty"`
// PrepTimeMinutes *int `json:"prep_time_minutes,omitempty"`
// CookTimeMinutes *int `json:"cook_time_minutes,omitempty"`
// Servings *int `json:"servings,omitempty"`
// Ingredients []ext.Ingredient `json:"ingredients,omitempty" jsonschema:"list of ingredients with name, quantity, unit"`
// Instructions []string `json:"instructions,omitempty" jsonschema:"ordered list of steps"`
// Tags []string `json:"tags,omitempty"`
// Rating *int `json:"rating,omitempty" jsonschema:"1-5 rating"`
// Notes string `json:"notes,omitempty"`
// }
type AddRecipeOutput struct {
Recipe ext.Recipe `json:"recipe"`
}
// type AddRecipeOutput struct {
// Recipe ext.Recipe `json:"recipe"`
// }
func (t *MealsTool) AddRecipe(ctx context.Context, _ *mcp.CallToolRequest, in AddRecipeInput) (*mcp.CallToolResult, AddRecipeOutput, error) {
if strings.TrimSpace(in.Name) == "" {
return nil, AddRecipeOutput{}, errRequiredField("name")
}
if in.Ingredients == nil {
in.Ingredients = []ext.Ingredient{}
}
if in.Instructions == nil {
in.Instructions = []string{}
}
if in.Tags == nil {
in.Tags = []string{}
}
recipe, err := t.store.AddRecipe(ctx, ext.Recipe{
Name: strings.TrimSpace(in.Name),
Cuisine: strings.TrimSpace(in.Cuisine),
PrepTimeMinutes: in.PrepTimeMinutes,
CookTimeMinutes: in.CookTimeMinutes,
Servings: in.Servings,
Ingredients: in.Ingredients,
Instructions: in.Instructions,
Tags: in.Tags,
Rating: in.Rating,
Notes: strings.TrimSpace(in.Notes),
})
if err != nil {
return nil, AddRecipeOutput{}, err
}
return nil, AddRecipeOutput{Recipe: recipe}, nil
}
// func (t *MealsTool) AddRecipe(ctx context.Context, _ *mcp.CallToolRequest, in AddRecipeInput) (*mcp.CallToolResult, AddRecipeOutput, error) {
// if strings.TrimSpace(in.Name) == "" {
// return nil, AddRecipeOutput{}, errRequiredField("name")
// }
// if in.Ingredients == nil {
// in.Ingredients = []ext.Ingredient{}
// }
// if in.Instructions == nil {
// in.Instructions = []string{}
// }
// if in.Tags == nil {
// in.Tags = []string{}
// }
// recipe, err := t.store.AddRecipe(ctx, ext.Recipe{
// Name: strings.TrimSpace(in.Name),
// Cuisine: strings.TrimSpace(in.Cuisine),
// PrepTimeMinutes: in.PrepTimeMinutes,
// CookTimeMinutes: in.CookTimeMinutes,
// Servings: in.Servings,
// Ingredients: in.Ingredients,
// Instructions: in.Instructions,
// Tags: in.Tags,
// Rating: in.Rating,
// Notes: strings.TrimSpace(in.Notes),
// })
// if err != nil {
// return nil, AddRecipeOutput{}, err
// }
// return nil, AddRecipeOutput{Recipe: recipe}, nil
// }
// search_recipes
// // search_recipes
type SearchRecipesInput struct {
Query string `json:"query,omitempty" jsonschema:"search by recipe name"`
Cuisine string `json:"cuisine,omitempty"`
Tags []string `json:"tags,omitempty" jsonschema:"filter by tags (all must match)"`
Ingredient string `json:"ingredient,omitempty" jsonschema:"filter by ingredient name"`
}
// type SearchRecipesInput struct {
// Query string `json:"query,omitempty" jsonschema:"search by recipe name"`
// Cuisine string `json:"cuisine,omitempty"`
// Tags []string `json:"tags,omitempty" jsonschema:"filter by tags (all must match)"`
// Ingredient string `json:"ingredient,omitempty" jsonschema:"filter by ingredient name"`
// }
type SearchRecipesOutput struct {
Recipes []ext.Recipe `json:"recipes"`
}
// type SearchRecipesOutput struct {
// Recipes []ext.Recipe `json:"recipes"`
// }
func (t *MealsTool) SearchRecipes(ctx context.Context, _ *mcp.CallToolRequest, in SearchRecipesInput) (*mcp.CallToolResult, SearchRecipesOutput, error) {
recipes, err := t.store.SearchRecipes(ctx, in.Query, in.Cuisine, in.Tags, in.Ingredient)
if err != nil {
return nil, SearchRecipesOutput{}, err
}
if recipes == nil {
recipes = []ext.Recipe{}
}
return nil, SearchRecipesOutput{Recipes: recipes}, nil
}
// func (t *MealsTool) SearchRecipes(ctx context.Context, _ *mcp.CallToolRequest, in SearchRecipesInput) (*mcp.CallToolResult, SearchRecipesOutput, error) {
// recipes, err := t.store.SearchRecipes(ctx, in.Query, in.Cuisine, in.Tags, in.Ingredient)
// if err != nil {
// return nil, SearchRecipesOutput{}, err
// }
// if recipes == nil {
// recipes = []ext.Recipe{}
// }
// return nil, SearchRecipesOutput{Recipes: recipes}, nil
// }
// update_recipe
// // update_recipe
type UpdateRecipeInput struct {
ID uuid.UUID `json:"id" jsonschema:"recipe id to update"`
Name string `json:"name" jsonschema:"recipe name"`
Cuisine string `json:"cuisine,omitempty"`
PrepTimeMinutes *int `json:"prep_time_minutes,omitempty"`
CookTimeMinutes *int `json:"cook_time_minutes,omitempty"`
Servings *int `json:"servings,omitempty"`
Ingredients []ext.Ingredient `json:"ingredients,omitempty"`
Instructions []string `json:"instructions,omitempty"`
Tags []string `json:"tags,omitempty"`
Rating *int `json:"rating,omitempty"`
Notes string `json:"notes,omitempty"`
}
// type UpdateRecipeInput struct {
// ID uuid.UUID `json:"id" jsonschema:"recipe id to update"`
// Name string `json:"name" jsonschema:"recipe name"`
// Cuisine string `json:"cuisine,omitempty"`
// PrepTimeMinutes *int `json:"prep_time_minutes,omitempty"`
// CookTimeMinutes *int `json:"cook_time_minutes,omitempty"`
// Servings *int `json:"servings,omitempty"`
// Ingredients []ext.Ingredient `json:"ingredients,omitempty"`
// Instructions []string `json:"instructions,omitempty"`
// Tags []string `json:"tags,omitempty"`
// Rating *int `json:"rating,omitempty"`
// Notes string `json:"notes,omitempty"`
// }
type UpdateRecipeOutput struct {
Recipe ext.Recipe `json:"recipe"`
}
// type UpdateRecipeOutput struct {
// Recipe ext.Recipe `json:"recipe"`
// }
func (t *MealsTool) UpdateRecipe(ctx context.Context, _ *mcp.CallToolRequest, in UpdateRecipeInput) (*mcp.CallToolResult, UpdateRecipeOutput, error) {
if strings.TrimSpace(in.Name) == "" {
return nil, UpdateRecipeOutput{}, errRequiredField("name")
}
if in.Ingredients == nil {
in.Ingredients = []ext.Ingredient{}
}
if in.Instructions == nil {
in.Instructions = []string{}
}
if in.Tags == nil {
in.Tags = []string{}
}
recipe, err := t.store.UpdateRecipe(ctx, in.ID, ext.Recipe{
Name: strings.TrimSpace(in.Name),
Cuisine: strings.TrimSpace(in.Cuisine),
PrepTimeMinutes: in.PrepTimeMinutes,
CookTimeMinutes: in.CookTimeMinutes,
Servings: in.Servings,
Ingredients: in.Ingredients,
Instructions: in.Instructions,
Tags: in.Tags,
Rating: in.Rating,
Notes: strings.TrimSpace(in.Notes),
})
if err != nil {
return nil, UpdateRecipeOutput{}, err
}
return nil, UpdateRecipeOutput{Recipe: recipe}, nil
}
// func (t *MealsTool) UpdateRecipe(ctx context.Context, _ *mcp.CallToolRequest, in UpdateRecipeInput) (*mcp.CallToolResult, UpdateRecipeOutput, error) {
// if strings.TrimSpace(in.Name) == "" {
// return nil, UpdateRecipeOutput{}, errRequiredField("name")
// }
// if in.Ingredients == nil {
// in.Ingredients = []ext.Ingredient{}
// }
// if in.Instructions == nil {
// in.Instructions = []string{}
// }
// if in.Tags == nil {
// in.Tags = []string{}
// }
// recipe, err := t.store.UpdateRecipe(ctx, in.ID, ext.Recipe{
// Name: strings.TrimSpace(in.Name),
// Cuisine: strings.TrimSpace(in.Cuisine),
// PrepTimeMinutes: in.PrepTimeMinutes,
// CookTimeMinutes: in.CookTimeMinutes,
// Servings: in.Servings,
// Ingredients: in.Ingredients,
// Instructions: in.Instructions,
// Tags: in.Tags,
// Rating: in.Rating,
// Notes: strings.TrimSpace(in.Notes),
// })
// if err != nil {
// return nil, UpdateRecipeOutput{}, err
// }
// return nil, UpdateRecipeOutput{Recipe: recipe}, nil
// }
// create_meal_plan
// // create_meal_plan
type CreateMealPlanInput struct {
WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to plan"`
Meals []ext.MealPlanInput `json:"meals" jsonschema:"list of meal entries for the week"`
}
// type CreateMealPlanInput struct {
// WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to plan"`
// Meals []ext.MealPlanInput `json:"meals" jsonschema:"list of meal entries for the week"`
// }
type CreateMealPlanOutput struct {
Entries []ext.MealPlanEntry `json:"entries"`
}
// type CreateMealPlanOutput struct {
// Entries []ext.MealPlanEntry `json:"entries"`
// }
func (t *MealsTool) CreateMealPlan(ctx context.Context, _ *mcp.CallToolRequest, in CreateMealPlanInput) (*mcp.CallToolResult, CreateMealPlanOutput, error) {
if len(in.Meals) == 0 {
return nil, CreateMealPlanOutput{}, errInvalidInput("meals are required")
}
entries, err := t.store.CreateMealPlan(ctx, in.WeekStart, in.Meals)
if err != nil {
return nil, CreateMealPlanOutput{}, err
}
if entries == nil {
entries = []ext.MealPlanEntry{}
}
return nil, CreateMealPlanOutput{Entries: entries}, nil
}
// func (t *MealsTool) CreateMealPlan(ctx context.Context, _ *mcp.CallToolRequest, in CreateMealPlanInput) (*mcp.CallToolResult, CreateMealPlanOutput, error) {
// if len(in.Meals) == 0 {
// return nil, CreateMealPlanOutput{}, errInvalidInput("meals are required")
// }
// entries, err := t.store.CreateMealPlan(ctx, in.WeekStart, in.Meals)
// if err != nil {
// return nil, CreateMealPlanOutput{}, err
// }
// if entries == nil {
// entries = []ext.MealPlanEntry{}
// }
// return nil, CreateMealPlanOutput{Entries: entries}, nil
// }
// get_meal_plan
// // get_meal_plan
type GetMealPlanInput struct {
WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to retrieve"`
}
// type GetMealPlanInput struct {
// WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to retrieve"`
// }
type GetMealPlanOutput struct {
Entries []ext.MealPlanEntry `json:"entries"`
}
// type GetMealPlanOutput struct {
// Entries []ext.MealPlanEntry `json:"entries"`
// }
func (t *MealsTool) GetMealPlan(ctx context.Context, _ *mcp.CallToolRequest, in GetMealPlanInput) (*mcp.CallToolResult, GetMealPlanOutput, error) {
entries, err := t.store.GetMealPlan(ctx, in.WeekStart)
if err != nil {
return nil, GetMealPlanOutput{}, err
}
if entries == nil {
entries = []ext.MealPlanEntry{}
}
return nil, GetMealPlanOutput{Entries: entries}, nil
}
// func (t *MealsTool) GetMealPlan(ctx context.Context, _ *mcp.CallToolRequest, in GetMealPlanInput) (*mcp.CallToolResult, GetMealPlanOutput, error) {
// entries, err := t.store.GetMealPlan(ctx, in.WeekStart)
// if err != nil {
// return nil, GetMealPlanOutput{}, err
// }
// if entries == nil {
// entries = []ext.MealPlanEntry{}
// }
// return nil, GetMealPlanOutput{Entries: entries}, nil
// }
// generate_shopping_list
// // generate_shopping_list
type GenerateShoppingListInput struct {
WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to generate shopping list for"`
}
// type GenerateShoppingListInput struct {
// WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to generate shopping list for"`
// }
type GenerateShoppingListOutput struct {
ShoppingList ext.ShoppingList `json:"shopping_list"`
}
// type GenerateShoppingListOutput struct {
// ShoppingList ext.ShoppingList `json:"shopping_list"`
// }
func (t *MealsTool) GenerateShoppingList(ctx context.Context, _ *mcp.CallToolRequest, in GenerateShoppingListInput) (*mcp.CallToolResult, GenerateShoppingListOutput, error) {
list, err := t.store.GenerateShoppingList(ctx, in.WeekStart)
if err != nil {
return nil, GenerateShoppingListOutput{}, err
}
return nil, GenerateShoppingListOutput{ShoppingList: list}, nil
}
// func (t *MealsTool) GenerateShoppingList(ctx context.Context, _ *mcp.CallToolRequest, in GenerateShoppingListInput) (*mcp.CallToolResult, GenerateShoppingListOutput, error) {
// list, err := t.store.GenerateShoppingList(ctx, in.WeekStart)
// if err != nil {
// return nil, GenerateShoppingListOutput{}, err
// }
// return nil, GenerateShoppingListOutput{ShoppingList: list}, nil
// }

344
internal/tools/plans.go Normal file
View File

@@ -0,0 +1,344 @@
package tools
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
type PlansTool struct {
store *store.DB
sessions *session.ActiveProjects
cfg config.SearchConfig
}
func NewPlansTool(db *store.DB, sessions *session.ActiveProjects, cfg config.SearchConfig) *PlansTool {
return &PlansTool{store: db, sessions: sessions, cfg: cfg}
}
// --- I/O types ---
type CreatePlanInput struct {
Title string `json:"title" jsonschema:"plan title"`
Description string `json:"description,omitempty"`
Status string `json:"status,omitempty" jsonschema:"draft|active|blocked|completed|cancelled|superseded"`
Priority string `json:"priority,omitempty" jsonschema:"low|medium|high|critical"`
Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"`
Owner string `json:"owner,omitempty"`
DueDate string `json:"due_date,omitempty" jsonschema:"RFC3339 timestamp"`
SupersedesPlanID *uuid.UUID `json:"supersedes_plan_id,omitempty"`
Tags []string `json:"tags,omitempty"`
}
type CreatePlanOutput struct {
Plan thoughttypes.Plan `json:"plan"`
}
type GetPlanInput struct {
ID uuid.UUID `json:"id" jsonschema:"plan id"`
}
type GetPlanOutput struct {
Plan thoughttypes.PlanDetail `json:"plan"`
}
type UpdatePlanInput struct {
ID uuid.UUID `json:"id" jsonschema:"plan id"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Status string `json:"status,omitempty" jsonschema:"draft|active|blocked|completed|cancelled|superseded"`
Priority string `json:"priority,omitempty" jsonschema:"low|medium|high|critical"`
Owner *string `json:"owner,omitempty" jsonschema:"empty string clears the owner"`
DueDate string `json:"due_date,omitempty" jsonschema:"RFC3339; omit to keep, 'clear' to remove"`
ClearDueDate bool `json:"clear_due_date,omitempty"`
CompletedAt string `json:"completed_at,omitempty" jsonschema:"RFC3339; omit to keep, 'clear' to remove"`
ClearCompletedAt bool `json:"clear_completed_at,omitempty"`
ReviewedBy *string `json:"reviewed_by,omitempty" jsonschema:"empty string clears the reviewer"`
MarkReviewed bool `json:"mark_reviewed,omitempty" jsonschema:"set last_reviewed_at to now"`
SupersedesPlanID *uuid.UUID `json:"supersedes_plan_id,omitempty"`
ClearSupersedesPlanID bool `json:"clear_supersedes_plan_id,omitempty"`
Tags *[]string `json:"tags,omitempty" jsonschema:"replaces all tags when provided; pass [] to clear"`
}
type UpdatePlanOutput struct {
Plan thoughttypes.Plan `json:"plan"`
}
type DeletePlanInput struct {
ID uuid.UUID `json:"id" jsonschema:"plan id"`
}
type DeletePlanOutput struct {
Deleted bool `json:"deleted"`
}
type ListPlansInput struct {
Limit int `json:"limit,omitempty"`
Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"`
Status string `json:"status,omitempty"`
Priority string `json:"priority,omitempty"`
Owner string `json:"owner,omitempty"`
Tag string `json:"tag,omitempty"`
Query string `json:"query,omitempty"`
}
type ListPlansOutput struct {
Plans []thoughttypes.Plan `json:"plans"`
}
type PlanDependencyInput struct {
PlanID uuid.UUID `json:"plan_id" jsonschema:"the plan that depends on another"`
DependsOnPlanID uuid.UUID `json:"depends_on_plan_id" jsonschema:"the plan that must complete first"`
}
type PlanRelatedInput struct {
PlanAID uuid.UUID `json:"plan_a_id"`
PlanBID uuid.UUID `json:"plan_b_id"`
}
type PlanLinkOutput struct {
OK bool `json:"ok"`
}
type PlanSkillInput struct {
PlanID uuid.UUID `json:"plan_id"`
SkillID uuid.UUID `json:"skill_id"`
}
type ListPlanSkillsInput struct {
PlanID uuid.UUID `json:"plan_id"`
}
type ListPlanSkillsOutput struct {
Skills []thoughttypes.AgentSkill `json:"skills"`
}
type PlanGuardrailInput struct {
PlanID uuid.UUID `json:"plan_id"`
GuardrailID uuid.UUID `json:"guardrail_id"`
}
type ListPlanGuardrailsInput struct {
PlanID uuid.UUID `json:"plan_id"`
}
type ListPlanGuardrailsOutput struct {
Guardrails []thoughttypes.AgentGuardrail `json:"guardrails"`
}
// --- Handlers ---
func (t *PlansTool) Create(ctx context.Context, req *mcp.CallToolRequest, in CreatePlanInput) (*mcp.CallToolResult, CreatePlanOutput, error) {
title := strings.TrimSpace(in.Title)
if title == "" {
return nil, CreatePlanOutput{}, errRequiredField("title")
}
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil {
return nil, CreatePlanOutput{}, err
}
plan := thoughttypes.Plan{
Title: title,
Description: strings.TrimSpace(in.Description),
Status: thoughttypes.PlanStatus(defaultString(strings.TrimSpace(in.Status), string(thoughttypes.PlanStatusDraft))),
Priority: thoughttypes.PlanPriority(defaultString(strings.TrimSpace(in.Priority), string(thoughttypes.PlanPriorityMedium))),
Owner: strings.TrimSpace(in.Owner),
SupersedesPlanID: in.SupersedesPlanID,
Tags: normalizeStringSlice(in.Tags),
}
if project != nil {
plan.ProjectID = &project.ID
}
if v := strings.TrimSpace(in.DueDate); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return nil, CreatePlanOutput{}, errInvalidField("due_date", "invalid due_date", "use RFC3339 format")
}
plan.DueDate = &t
}
created, err := t.store.CreatePlan(ctx, plan)
if err != nil {
return nil, CreatePlanOutput{}, err
}
return nil, CreatePlanOutput{Plan: created}, nil
}
func (t *PlansTool) Get(ctx context.Context, _ *mcp.CallToolRequest, in GetPlanInput) (*mcp.CallToolResult, GetPlanOutput, error) {
detail, err := t.store.GetPlanDetail(ctx, in.ID)
if err != nil {
return nil, GetPlanOutput{}, err
}
return nil, GetPlanOutput{Plan: detail}, nil
}
func (t *PlansTool) Update(ctx context.Context, _ *mcp.CallToolRequest, in UpdatePlanInput) (*mcp.CallToolResult, UpdatePlanOutput, error) {
u := thoughttypes.PlanUpdate{
ReviewedBy: in.ReviewedBy,
MarkReviewed: in.MarkReviewed,
ClearDueDate: in.ClearDueDate,
ClearCompletedAt: in.ClearCompletedAt,
ClearSupersedesPlanID: in.ClearSupersedesPlanID,
SupersedesPlanID: in.SupersedesPlanID,
Tags: in.Tags,
Owner: in.Owner,
}
if v := strings.TrimSpace(in.Title); v != "" {
u.Title = &v
}
if v := strings.TrimSpace(in.Description); v != "" {
u.Description = &v
}
if v := strings.TrimSpace(in.Status); v != "" {
u.Status = &v
}
if v := strings.TrimSpace(in.Priority); v != "" {
u.Priority = &v
}
if v := strings.TrimSpace(in.DueDate); v != "" && !in.ClearDueDate {
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return nil, UpdatePlanOutput{}, errInvalidField("due_date", "invalid due_date", "use RFC3339 format")
}
u.DueDate = &parsed
}
if v := strings.TrimSpace(in.CompletedAt); v != "" && !in.ClearCompletedAt {
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return nil, UpdatePlanOutput{}, errInvalidField("completed_at", "invalid completed_at", "use RFC3339 format")
}
u.CompletedAt = &parsed
}
plan, err := t.store.UpdatePlan(ctx, in.ID, u)
if err != nil {
return nil, UpdatePlanOutput{}, err
}
return nil, UpdatePlanOutput{Plan: plan}, nil
}
func (t *PlansTool) Delete(ctx context.Context, _ *mcp.CallToolRequest, in DeletePlanInput) (*mcp.CallToolResult, DeletePlanOutput, error) {
if err := t.store.DeletePlan(ctx, in.ID); err != nil {
return nil, DeletePlanOutput{}, err
}
return nil, DeletePlanOutput{Deleted: true}, nil
}
func (t *PlansTool) List(ctx context.Context, req *mcp.CallToolRequest, in ListPlansInput) (*mcp.CallToolResult, ListPlansOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil {
return nil, ListPlansOutput{}, err
}
filter := thoughttypes.PlanFilter{
Limit: normalizeLimit(in.Limit, t.cfg),
Status: strings.TrimSpace(in.Status),
Priority: strings.TrimSpace(in.Priority),
Owner: strings.TrimSpace(in.Owner),
Tag: strings.TrimSpace(in.Tag),
Query: strings.TrimSpace(in.Query),
}
if project != nil {
filter.ProjectID = &project.ID
}
plans, err := t.store.ListPlans(ctx, filter)
if err != nil {
return nil, ListPlansOutput{}, err
}
return nil, ListPlansOutput{Plans: plans}, nil
}
func (t *PlansTool) AddDependency(ctx context.Context, _ *mcp.CallToolRequest, in PlanDependencyInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if in.PlanID == in.DependsOnPlanID {
return nil, PlanLinkOutput{}, errInvalidField("depends_on_plan_id", "a plan cannot depend on itself", "use a different plan id")
}
if err := t.store.AddPlanDependency(ctx, in.PlanID, in.DependsOnPlanID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) RemoveDependency(ctx context.Context, _ *mcp.CallToolRequest, in PlanDependencyInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if err := t.store.RemovePlanDependency(ctx, in.PlanID, in.DependsOnPlanID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) AddRelated(ctx context.Context, _ *mcp.CallToolRequest, in PlanRelatedInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if in.PlanAID == in.PlanBID {
return nil, PlanLinkOutput{}, errInvalidField("plan_b_id", "a plan cannot be related to itself", "use a different plan id")
}
if err := t.store.AddRelatedPlan(ctx, in.PlanAID, in.PlanBID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) RemoveRelated(ctx context.Context, _ *mcp.CallToolRequest, in PlanRelatedInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if err := t.store.RemoveRelatedPlan(ctx, in.PlanAID, in.PlanBID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) AddSkill(ctx context.Context, _ *mcp.CallToolRequest, in PlanSkillInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if err := t.store.AddPlanSkill(ctx, in.PlanID, in.SkillID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) RemoveSkill(ctx context.Context, _ *mcp.CallToolRequest, in PlanSkillInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if err := t.store.RemovePlanSkill(ctx, in.PlanID, in.SkillID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) ListSkills(ctx context.Context, _ *mcp.CallToolRequest, in ListPlanSkillsInput) (*mcp.CallToolResult, ListPlanSkillsOutput, error) {
skills, err := t.store.ListPlanSkills(ctx, in.PlanID)
if err != nil {
return nil, ListPlanSkillsOutput{}, err
}
if skills == nil {
skills = []thoughttypes.AgentSkill{}
}
return nil, ListPlanSkillsOutput{Skills: skills}, nil
}
func (t *PlansTool) AddGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in PlanGuardrailInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if err := t.store.AddPlanGuardrail(ctx, in.PlanID, in.GuardrailID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) RemoveGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in PlanGuardrailInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if err := t.store.RemovePlanGuardrail(ctx, in.PlanID, in.GuardrailID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) ListGuardrails(ctx context.Context, _ *mcp.CallToolRequest, in ListPlanGuardrailsInput) (*mcp.CallToolResult, ListPlanGuardrailsOutput, error) {
guardrails, err := t.store.ListPlanGuardrails(ctx, in.PlanID)
if err != nil {
return nil, ListPlanGuardrailsOutput{}, err
}
if guardrails == nil {
guardrails = []thoughttypes.AgentGuardrail{}
}
return nil, ListPlanGuardrailsOutput{Guardrails: guardrails}, nil
}

83
internal/types/plan.go Normal file
View File

@@ -0,0 +1,83 @@
package types
import (
"time"
"github.com/google/uuid"
)
type PlanStatus string
const (
PlanStatusDraft PlanStatus = "draft"
PlanStatusActive PlanStatus = "active"
PlanStatusBlocked PlanStatus = "blocked"
PlanStatusCompleted PlanStatus = "completed"
PlanStatusCancelled PlanStatus = "cancelled"
PlanStatusSuperseded PlanStatus = "superseded"
)
type PlanPriority string
const (
PlanPriorityLow PlanPriority = "low"
PlanPriorityMedium PlanPriority = "medium"
PlanPriorityHigh PlanPriority = "high"
PlanPriorityCritical PlanPriority = "critical"
)
type Plan struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status PlanStatus `json:"status"`
Priority PlanPriority `json:"priority"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
Owner string `json:"owner,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
ReviewedBy string `json:"reviewed_by,omitempty"`
LastReviewedAt *time.Time `json:"last_reviewed_at,omitempty"`
SupersedesPlanID *uuid.UUID `json:"supersedes_plan_id,omitempty"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PlanDetail enriches Plan with all related records, returned by get_plan.
type PlanDetail struct {
Plan
DependsOn []Plan `json:"depends_on"`
Blocks []Plan `json:"blocks"`
RelatedPlans []Plan `json:"related_plans"`
Skills []AgentSkill `json:"skills"`
Guardrails []AgentGuardrail `json:"guardrails"`
}
type PlanFilter struct {
Limit int
ProjectID *uuid.UUID
Status string
Priority string
Owner string
Tag string
Query string
}
// PlanUpdate describes a partial update; nil pointer fields are not touched.
type PlanUpdate struct {
Title *string
Description *string
Status *string
Priority *string
Owner *string // "" to clear
DueDate *time.Time // nil = no change
ClearDueDate bool // true = set NULL (takes priority over DueDate)
CompletedAt *time.Time
ClearCompletedAt bool
ReviewedBy *string // "" to clear
MarkReviewed bool // sets last_reviewed_at = now()
SupersedesPlanID *uuid.UUID
ClearSupersedesPlanID bool
Tags *[]string // nil = no change; replace when non-nil
}

View File

@@ -1,6 +1,6 @@
# AMCS Memory Instructions
AMCS (Avalon Memory Crystal Server) is an MCP server for capturing and retrieving thoughts, memory, and project context. It is backed by Postgres with pgvector for semantic search.
AMCS (Avalon Memory Control Service) is an MCP server for capturing and retrieving thoughts, memory, and project context. It is backed by Postgres with pgvector for semantic search.
`amcs-cli` is a pre-built CLI that connects to the AMCS MCP server so agents do not need to implement their own HTTP MCP client. Download it from https://git.warky.dev/wdevs/amcs/releases
@@ -89,6 +89,32 @@ Do not abandon the project scope or retry without a project. The project simply
- Do not base64-encode a file to pass it to `save_file` if an `amcs://files/{id}` URI is already available from a prior `upload_file` or HTTP upload.
- When saving, choose the narrowest correct scope: project if project-specific, global if not.
## Plans
Plans are structured, trackable work items linked to projects. Use plans for multi-step goals, workstreams, or anything that needs an owner, due date, status lifecycle, or explicit dependency tracking.
- **Status lifecycle**: `draft``active``blocked` | `completed` | `cancelled` | `superseded`
- **Priority**: `low`, `medium` (default), `high`, `critical`
- Create plans with `create_plan` (required: `title`; optional: `description`, `status`, `priority`, `project`, `owner`, `due_date`, `supersedes_plan_id`, `tags`).
- Retrieve a full plan with `get_plan` — returns the plan plus `depends_on`, `blocks`, `related_plans`, `skills`, and `guardrails` in a single call.
- Partially update a plan with `update_plan` (only provided fields change). Use `mark_reviewed: true` to stamp `last_reviewed_at` without manually passing a timestamp.
- List and filter with `list_plans` (project/status/priority/owner/tag/query).
- Delete permanently with `delete_plan`.
**Dependencies** (directional — "A cannot proceed until B is done"):
- `add_plan_dependency` / `remove_plan_dependency` using `plan_id` and `depends_on_plan_id`.
- `get_plan` returns `depends_on` (plans this plan waits on) and `blocks` (plans waiting on this one).
**Related plans** (bidirectional — thematically linked, no ordering):
- `add_related_plan` / `remove_related_plan` using `plan_a_id` and `plan_b_id` (order does not matter).
**Plan skills and guardrails** (agent behaviour scoped to a plan):
- `add_plan_skill` / `remove_plan_skill` / `list_plan_skills`
- `add_plan_guardrail` / `remove_plan_guardrail` / `list_plan_guardrails`
- Load plan skills and guardrails alongside project skills/guardrails when working within a specific plan's scope.
**Freshness**: use `last_reviewed_at` and `reviewed_by` to track whether a plan is current. Set `mark_reviewed: true` on `update_plan` after reviewing a plan so staleness is visible in `list_plans` results.
## Tool Annotations
As you learn non-obvious behaviours, gotchas, or workflow patterns for individual tools, persist them with `annotate_tool`:
@@ -109,4 +135,4 @@ Notes are returned by `describe_tools` in future sessions. Annotate whenever you
## Short Operational Form
At the start of every session, call `describe_tools` to read the full tool list and any accumulated usage notes. Use AMCS memory in project scope when the current work matches a known project; if no clear project matches, global notebook memory is allowed for non-project-specific information. At the start of every project session call `list_project_skills` and `list_project_guardrails` and apply what is returned; only create new skills or guardrails if none exist. If your MCP client does not preserve sessions across calls, pass `project` explicitly instead of relying on `set_active_project`. Store raw/durable notes with `capture_thought`, and store curated durable lessons with `add_learning`. For binary files or files larger than 10 MB, call `upload_file` with `content_path` to stage the file and get an `amcs://files/{id}` URI, then pass that URI to `save_file` as `content_uri` to link it to a thought. For small files, use `save_file` or `upload_file` with `content_base64` directly. Browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Stored files can also be read as raw binary via MCP resources at `amcs://files/{id}`. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user. If a tool returns `project_not_found`, call `create_project` with that name and retry — never drop the project scope. Whenever you discover a non-obvious tool behaviour, gotcha, or workflow pattern, record it with `annotate_tool` so future sessions benefit.
At the start of every session, call `describe_tools` to read the full tool list and any accumulated usage notes. Use AMCS memory in project scope when the current work matches a known project; if no clear project matches, global notebook memory is allowed for non-project-specific information. At the start of every project session call `list_project_skills` and `list_project_guardrails` and apply what is returned; only create new skills or guardrails if none exist. If your MCP client does not preserve sessions across calls, pass `project` explicitly instead of relying on `set_active_project`. Store raw/durable notes with `capture_thought`, store curated durable lessons with `add_learning`, and track structured multi-step goals with `create_plan`. Use `get_plan` to load a plan's full context including dependencies, related plans, and linked skills/guardrails. Stamp `last_reviewed_at` on plans you review with `update_plan mark_reviewed: true`. For binary files or files larger than 10 MB, call `upload_file` with `content_path` to stage the file and get an `amcs://files/{id}` URI, then pass that URI to `save_file` as `content_uri` to link it to a thought. For small files, use `save_file` or `upload_file` with `content_base64` directly. Browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Stored files can also be read as raw binary via MCP resources at `amcs://files/{id}`. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user. If a tool returns `project_not_found`, call `create_project` with that name and retry — never drop the project scope. Whenever you discover a non-obvious tool behaviour, gotcha, or workflow pattern, record it with `annotate_tool` so future sessions benefit.

File diff suppressed because it is too large Load Diff

92
schema/plans.dbml Normal file
View File

@@ -0,0 +1,92 @@
Table plans {
id uuid [pk, default: `gen_random_uuid()`]
title text [not null]
description text [not null, default: '']
status text [not null, default: 'draft'] // draft, active, blocked, completed, cancelled, superseded
priority text [not null, default: 'medium'] // low, medium, high, critical
project_id uuid [ref: > projects.guid]
owner text
due_date timestamptz
completed_at timestamptz
reviewed_by text
last_reviewed_at timestamptz
supersedes_plan_id uuid [ref: > plans.id]
tags "text[]" [not null, default: `'{}'`]
created_at timestamptz [not null, default: `now()`]
updated_at timestamptz [not null, default: `now()`]
indexes {
project_id
status
priority
owner
due_date
last_reviewed_at
tags [type: gin]
title [type: gin]
}
}
// Directional: plan_id cannot proceed until depends_on_plan_id is complete
Table plan_dependencies {
id serial [pk]
plan_id uuid [not null, ref: > plans.id]
depends_on_plan_id uuid [not null, ref: > plans.id]
created_at timestamptz [not null, default: `now()`]
indexes {
(plan_id, depends_on_plan_id) [unique]
plan_id
depends_on_plan_id
}
}
// Bidirectional: store with plan_a_id < plan_b_id to avoid duplicates
Table plan_related_plans {
id serial [pk]
plan_a_id uuid [not null, ref: > plans.id]
plan_b_id uuid [not null, ref: > plans.id]
created_at timestamptz [not null, default: `now()`]
indexes {
(plan_a_id, plan_b_id) [unique]
plan_a_id
plan_b_id
}
}
Table plan_skills {
id serial [pk]
plan_id uuid [not null, ref: > plans.id]
skill_id uuid [not null, ref: > agent_skills.id]
created_at timestamptz [not null, default: `now()`]
indexes {
(plan_id, skill_id) [unique]
plan_id
}
}
Table plan_guardrails {
id serial [pk]
plan_id uuid [not null, ref: > plans.id]
guardrail_id uuid [not null, ref: > agent_guardrails.id]
created_at timestamptz [not null, default: `now()`]
indexes {
(plan_id, guardrail_id) [unique]
plan_id
}
}
// Cross-file refs (for relspecgo merge)
Ref: plans.project_id > projects.guid [delete: set null]
Ref: plans.supersedes_plan_id > plans.id [delete: set null]
Ref: plan_dependencies.plan_id > plans.id [delete: cascade]
Ref: plan_dependencies.depends_on_plan_id > plans.id [delete: cascade]
Ref: plan_related_plans.plan_a_id > plans.id [delete: cascade]
Ref: plan_related_plans.plan_b_id > plans.id [delete: cascade]
Ref: plan_skills.plan_id > plans.id [delete: cascade]
Ref: plan_skills.skill_id > agent_skills.id [delete: cascade]
Ref: plan_guardrails.plan_id > plans.id [delete: cascade]
Ref: plan_guardrails.guardrail_id > agent_guardrails.id [delete: cascade]

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -9,7 +9,7 @@
content="AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools."
/>
</head>
<body class="bg-slate-950">
<body class="bg-slate-950" data-theme="amcs">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>

View File

@@ -21,12 +21,12 @@
"vite": "^8.0.10"
},
"dependencies": {
"@sentry/svelte": "^10.50.0",
"@sentry/svelte": "^10.51.0",
"@skeletonlabs/skeleton": "^4.15.2",
"@skeletonlabs/skeleton-svelte": "^4.15.2",
"@tanstack/svelte-virtual": "^3.13.24",
"@warkypublic/artemis-kit": "^1.0.10",
"@warkypublic/resolvespec-js": "^1.0.1",
"@warkypublic/svelix": "^0.1.39"
"@warkypublic/svelix": "^0.1.40"
}
}
}

82
ui/pnpm-lock.yaml generated
View File

@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@sentry/svelte':
specifier: ^10.50.0
version: 10.50.0(svelte@5.55.5)
specifier: ^10.51.0
version: 10.51.0(svelte@5.55.5)
'@skeletonlabs/skeleton':
specifier: ^4.15.2
version: 4.15.2(tailwindcss@4.2.4)
@@ -27,8 +27,8 @@ importers:
specifier: ^1.0.1
version: 1.0.1
'@warkypublic/svelix':
specifier: ^0.1.39
version: 0.1.39(highlight.js@11.8.0)(svelte@5.55.5)(unified@11.0.5)
specifier: ^0.1.40
version: 0.1.40(highlight.js@11.8.0)(svelte@5.55.5)(unified@11.0.5)
devDependencies:
'@sveltejs/vite-plugin-svelte':
specifier: ^7.0.0
@@ -315,32 +315,32 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.17':
resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==}
'@sentry-internal/browser-utils@10.50.0':
resolution: {integrity: sha512-42bxyRTxnCmYlWnvz4CxikuQNanw8UNma2WJrtxJ0f1MAJV2GhQGSHDLnA+lvFlmiz6qct3pfen/NXGyOTegTA==}
'@sentry-internal/browser-utils@10.51.0':
resolution: {integrity: sha512-lNKBS4P7RUvf1niojXQWe9bU3gnBUCbST4Dj0pSiyat1N96cXVyHkeE+uGxowD0RrVWhs+kGHiVX3FcmRWF6sA==}
engines: {node: '>=18'}
'@sentry-internal/feedback@10.50.0':
resolution: {integrity: sha512-0k9XZF0wn86f77mIO2U3gNNyDZooy139CnEanRzHinrN106vVzvBZ6TUEQoHtoO1fqQxr+nWWVrqV/PXUqk47w==}
'@sentry-internal/feedback@10.51.0':
resolution: {integrity: sha512-bCM95bcpphx28e6aU0bwRLxOgwosYsdNzezM1sM0pVOkb0TB3hDFRamramVDK+/Hp1o8qmRxS4c5w/A7YBZGkA==}
engines: {node: '>=18'}
'@sentry-internal/replay-canvas@10.50.0':
resolution: {integrity: sha512-jx6RKBmcJSWdI92qDGS/sBv1w+7Cww879Z/moX7bw7ipHa/Ts3iDcB3rgZwvhmi17U+mvYsbJeL2DXkPo3TjPw==}
'@sentry-internal/replay-canvas@10.51.0':
resolution: {integrity: sha512-8PW1Pp+Yl3lPwYqhBCr5SgkuhDanu9ZLzUqD2bPKL/ElqbM2eDVIWxq4z4ZzePrmZa6IcCjTv6sVQJ7Z4dLyLA==}
engines: {node: '>=18'}
'@sentry-internal/replay@10.50.0':
resolution: {integrity: sha512-51FYNfnvVLAWw1rrEWPFfwHuMRb9mkVCFGA4J9/un7SpeGBsQDziGB0Di4fsCxI7+EdSBpfLHPF0csKtCCw0oQ==}
'@sentry-internal/replay@10.51.0':
resolution: {integrity: sha512-jCpI5HXSwK6ZT2HX70+mDRciAocHzSiDk4DTgvzV69Wvd+Ei5WLgE+d39eaEPsm8lUC0Ydntb5sJIB6uG9D4bw==}
engines: {node: '>=18'}
'@sentry/browser@10.50.0':
resolution: {integrity: sha512-1f6rAvET6myiTaSeYqvaaBwvq1LfxqWjAPIoAW/NVC9bPMkeEcuvgDajHrnZMrBeWoJ81NMyoLkyX+iOc7MoFA==}
'@sentry/browser@10.51.0':
resolution: {integrity: sha512-Zdc0sKfenxUtW/OGhtJ7xHFN44bXR7YqxJ1zBDzlZfW0nTbeTTUZBq9z5NUw6qdS0Vs/i3V4qzAKTbRKWfqSEA==}
engines: {node: '>=18'}
'@sentry/core@10.50.0':
resolution: {integrity: sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg==}
'@sentry/core@10.51.0':
resolution: {integrity: sha512-Y45V/YXvVLEXmOdkbD1oG1gkRWFi9guCEGg3PlIlIpRjAbZUrvLGgjRJIc1E7XpSzmOnWbs5BbUxMv4PDaPj2w==}
engines: {node: '>=18'}
'@sentry/svelte@10.50.0':
resolution: {integrity: sha512-pkd9HNpZN+8x9i8n24fpV+Q3/sKDkBKyJ29iNzbhGnZ3CeRPwKxwQOoiBBPQkllYWzr/a7cYFtBKNLdpmTFCOg==}
'@sentry/svelte@10.51.0':
resolution: {integrity: sha512-2/OwIs+WXk+H/CAnWeiQMvXx5EG8LxCtqu4m+HdHtJTUQtERC388zNShZTJU9YYR71KoDOVAU0Q6B2sKNcssFA==}
engines: {node: '>=18'}
peerDependencies:
svelte: 3.x || 4.x || 5.x
@@ -758,8 +758,8 @@ packages:
resolution: {integrity: sha512-uXP1HouxpOKXfwE6qpy0gCcrMPIgjDT53aVGkfork4QejRSunbKWSKKawW2nIm7RnyFhSjPILMXcnT5xUiXOew==}
engines: {node: '>=18'}
'@warkypublic/svelix@0.1.39':
resolution: {integrity: sha512-CeKOyabAXTt5MXzRkQyG0G7+1wwgYD/e4+fX9gRsQWxlVVrHv8qdbK1HxHHKMmu4ZW9csf0dXWcEIt/TSM9qAg==}
'@warkypublic/svelix@0.1.40':
resolution: {integrity: sha512-Pn4T+VVI1Pfrtkbr61oqVyhaUTySU0r6gti9SeSqL+ZJeRfXAj+ZTuwlHzk0w9BxrwlWDnVinGyrpbSjnwb+iQ==}
peerDependencies:
svelte: ^5.0.0
@@ -2062,38 +2062,38 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.17': {}
'@sentry-internal/browser-utils@10.50.0':
'@sentry-internal/browser-utils@10.51.0':
dependencies:
'@sentry/core': 10.50.0
'@sentry/core': 10.51.0
'@sentry-internal/feedback@10.50.0':
'@sentry-internal/feedback@10.51.0':
dependencies:
'@sentry/core': 10.50.0
'@sentry/core': 10.51.0
'@sentry-internal/replay-canvas@10.50.0':
'@sentry-internal/replay-canvas@10.51.0':
dependencies:
'@sentry-internal/replay': 10.50.0
'@sentry/core': 10.50.0
'@sentry-internal/replay': 10.51.0
'@sentry/core': 10.51.0
'@sentry-internal/replay@10.50.0':
'@sentry-internal/replay@10.51.0':
dependencies:
'@sentry-internal/browser-utils': 10.50.0
'@sentry/core': 10.50.0
'@sentry-internal/browser-utils': 10.51.0
'@sentry/core': 10.51.0
'@sentry/browser@10.50.0':
'@sentry/browser@10.51.0':
dependencies:
'@sentry-internal/browser-utils': 10.50.0
'@sentry-internal/feedback': 10.50.0
'@sentry-internal/replay': 10.50.0
'@sentry-internal/replay-canvas': 10.50.0
'@sentry/core': 10.50.0
'@sentry-internal/browser-utils': 10.51.0
'@sentry-internal/feedback': 10.51.0
'@sentry-internal/replay': 10.51.0
'@sentry-internal/replay-canvas': 10.51.0
'@sentry/core': 10.51.0
'@sentry/core@10.50.0': {}
'@sentry/core@10.51.0': {}
'@sentry/svelte@10.50.0(svelte@5.55.5)':
'@sentry/svelte@10.51.0(svelte@5.55.5)':
dependencies:
'@sentry/browser': 10.50.0
'@sentry/core': 10.50.0
'@sentry/browser': 10.51.0
'@sentry/core': 10.51.0
magic-string: 0.30.21
svelte: 5.55.5
@@ -2556,7 +2556,7 @@ snapshots:
dependencies:
uuid: 13.0.0
'@warkypublic/svelix@0.1.39(highlight.js@11.8.0)(svelte@5.55.5)(unified@11.0.5)':
'@warkypublic/svelix@0.1.40(highlight.js@11.8.0)(svelte@5.55.5)(unified@11.0.5)':
dependencies:
'@cartamd/plugin-anchor': 2.2.0(carta-md@4.11.2(svelte@5.55.5))
'@cartamd/plugin-attachment': 4.2.0(carta-md@4.11.2(svelte@5.55.5))

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import LoginPage from './components/auth/LoginPage.svelte';
import AdminShell from './components/shell/AdminShell.svelte';
import type { ShellPage, StatusResponse } from './types';
import type { PublicStatusResponse, ShellPage, StatusResponse } from './types';
import { fromStore } from 'svelte/store';
import {
ensureApiURL,
@@ -20,6 +20,9 @@
let data = $state<StatusResponse | null>(null);
let loading = $state(false);
let error = $state('');
let publicStatus = $state<PublicStatusResponse | null>(null);
let publicStatusLoading = $state(false);
let publicStatusError = $state('');
let currentPage = $state<ShellPage>('dashboard');
ensureApiURL(import.meta.env.VITE_API_URL);
@@ -57,7 +60,7 @@
const token = await loginWithCredentials(username, password);
await GlobalStateStore.getState().login(token, { username });
authMessage = 'Login successful.';
await loadStatus();
await loadDashboardStatus();
} catch (err) {
authError = err instanceof Error ? err.message : 'Login failed.';
} finally {
@@ -88,7 +91,7 @@
authMessage = 'OAuth login complete. Welcome back.';
window.history.replaceState({}, '', '/');
await loadStatus();
await loadDashboardStatus();
} catch (err) {
authError = err instanceof Error ? err.message : 'OAuth callback failed.';
} finally {
@@ -100,14 +103,23 @@
await GlobalStateStore.getState().logout();
authMessage = 'Logged out.';
authError = '';
await loadPublicStatus();
}
async function loadStatus(): Promise<void> {
async function loadDashboardStatus(): Promise<void> {
loading = true;
error = '';
try {
const response = await fetch('/api/status');
const token = GlobalStateStore.getState().session?.authToken;
if (!token) {
throw new Error('Missing auth token for dashboard status.');
}
const response = await fetch('/api/status', {
headers: {
Authorization: `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Status request failed with ${response.status}`);
}
@@ -141,6 +153,28 @@
}
}
async function loadPublicStatus(): Promise<void> {
publicStatusLoading = true;
publicStatusError = '';
try {
const response = await fetch('/status');
if (!response.ok) {
throw new Error(`Public status request failed with ${response.status}`);
}
const raw = (await response.json()) as Partial<PublicStatusResponse> | null;
publicStatus = {
connected_count: raw?.connected_count ?? 0,
connected_window: raw?.connected_window ?? 'last 10 minutes',
entries: Array.isArray(raw?.entries) ? raw.entries : []
};
} catch (err) {
publicStatusError = err instanceof Error ? err.message : 'Failed to load public status';
} finally {
publicStatusLoading = false;
}
}
onMount(async () => {
if (typeof window !== 'undefined') {
setCurrentPath(window.location.pathname);
@@ -154,8 +188,10 @@
await GlobalStateStore.getState().fetchData();
if (GlobalStateStore.getState().isLoggedIn()) {
await loadStatus();
await loadDashboardStatus();
return;
}
await loadPublicStatus();
});
</script>
@@ -163,7 +199,7 @@
<title>AMCS Admin</title>
</svelte:head>
<div class="min-h-screen bg-slate-950 text-slate-100">
<div data-theme="amcs" class="min-h-screen bg-slate-950 text-slate-100">
{#if !isLoggedIn.current}
<LoginPage
{isOAuthCallback}
@@ -171,6 +207,9 @@
{authBusy}
{authError}
{authMessage}
statusData={publicStatus}
statusLoading={publicStatusLoading}
statusError={publicStatusError}
onlogin={handleCredentialLogin}
/>
{:else}
@@ -181,7 +220,7 @@
{error}
onlogout={logout}
onnavigate={(page) => { currentPage = page; }}
onrefresh={loadStatus}
onrefresh={loadDashboardStatus}
/>
{/if}
</div>

218
ui/src/amcs.theme.css Normal file
View File

@@ -0,0 +1,218 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
[data-theme='amcs'] {
--text-scaling: 1;
--base-font-color: var(--color-surface-950);
--base-font-color-dark: var(--color-surface-50);
--base-font-family: 'Inter', system-ui, sans-serif;
--base-font-size: inherit;
--base-line-height: 1.5;
--base-font-weight: 400;
--base-font-style: normal;
--base-letter-spacing: 0;
--heading-font-color: inherit;
--heading-font-color-dark: inherit;
--heading-font-family: 'Inter', system-ui, sans-serif;
--heading-font-weight: 600;
--heading-font-style: normal;
--heading-letter-spacing: -0.01em;
--anchor-font-color: var(--color-primary-400);
--anchor-font-color-dark: var(--color-primary-300);
--anchor-font-family: inherit;
--anchor-font-size: inherit;
--anchor-line-height: inherit;
--anchor-font-weight: inherit;
--anchor-font-style: inherit;
--anchor-letter-spacing: inherit;
--anchor-text-decoration: none;
--anchor-text-decoration-hover: underline;
--anchor-text-decoration-active: none;
--anchor-text-decoration-focus: none;
--spacing: 0.25rem;
--radius-base: 0.75rem;
--radius-container: 1rem;
--default-border-width: 1px;
--default-divide-width: 1px;
--default-ring-width: 1px;
--body-background-color: var(--color-surface-50);
--body-background-color-dark: var(--color-surface-950);
/* Primary: AMCS cyan */
--color-primary-50: oklch(98.4% 0.019 200.87deg);
--color-primary-100: oklch(95.6% 0.045 203.39deg);
--color-primary-200: oklch(91.7% 0.08 205.04deg);
--color-primary-300: oklch(86.5% 0.127 207.08deg);
--color-primary-400: oklch(78.9% 0.154 211.53deg);
--color-primary-500: oklch(71.5% 0.143 215.22deg);
--color-primary-600: oklch(60.9% 0.126 221.72deg);
--color-primary-700: oklch(51.9% 0.105 223.13deg);
--color-primary-800: oklch(45% 0.085 224.28deg);
--color-primary-900: oklch(39.8% 0.07 227.39deg);
--color-primary-950: oklch(30.2% 0.056 229.7deg);
--color-primary-contrast-dark: var(--color-primary-950);
--color-primary-contrast-light: var(--color-primary-50);
--color-primary-contrast-50: var(--color-primary-contrast-dark);
--color-primary-contrast-100: var(--color-primary-contrast-dark);
--color-primary-contrast-200: var(--color-primary-contrast-dark);
--color-primary-contrast-300: var(--color-primary-contrast-dark);
--color-primary-contrast-400: var(--color-primary-contrast-dark);
--color-primary-contrast-500: var(--color-primary-contrast-dark);
--color-primary-contrast-600: var(--color-primary-contrast-light);
--color-primary-contrast-700: var(--color-primary-contrast-light);
--color-primary-contrast-800: var(--color-primary-contrast-light);
--color-primary-contrast-900: var(--color-primary-contrast-light);
--color-primary-contrast-950: var(--color-primary-contrast-light);
/* Secondary: deeper steel blue */
--color-secondary-50: oklch(97.7% 0.006 247.9deg);
--color-secondary-100: oklch(95.2% 0.011 250.1deg);
--color-secondary-200: oklch(90.7% 0.021 252.8deg);
--color-secondary-300: oklch(83.8% 0.036 254.6deg);
--color-secondary-400: oklch(70.4% 0.05 256.7deg);
--color-secondary-500: oklch(55.4% 0.046 257.4deg);
--color-secondary-600: oklch(46.1% 0.043 257.3deg);
--color-secondary-700: oklch(38.7% 0.044 257.3deg);
--color-secondary-800: oklch(30.5% 0.043 260deg);
--color-secondary-900: oklch(24.6% 0.042 264deg);
--color-secondary-950: oklch(17.8% 0.041 265deg);
--color-secondary-contrast-dark: var(--color-secondary-950);
--color-secondary-contrast-light: var(--color-secondary-50);
--color-secondary-contrast-50: var(--color-secondary-contrast-dark);
--color-secondary-contrast-100: var(--color-secondary-contrast-dark);
--color-secondary-contrast-200: var(--color-secondary-contrast-dark);
--color-secondary-contrast-300: var(--color-secondary-contrast-dark);
--color-secondary-contrast-400: var(--color-secondary-contrast-light);
--color-secondary-contrast-500: var(--color-secondary-contrast-light);
--color-secondary-contrast-600: var(--color-secondary-contrast-light);
--color-secondary-contrast-700: var(--color-secondary-contrast-light);
--color-secondary-contrast-800: var(--color-secondary-contrast-light);
--color-secondary-contrast-900: var(--color-secondary-contrast-light);
--color-secondary-contrast-950: var(--color-secondary-contrast-light);
/* Tertiary: bright highlight cyan */
--color-tertiary-50: oklch(98.5% 0.02 214deg);
--color-tertiary-100: oklch(96.2% 0.05 214deg);
--color-tertiary-200: oklch(92.4% 0.088 213deg);
--color-tertiary-300: oklch(87.1% 0.132 212deg);
--color-tertiary-400: oklch(80.2% 0.157 211deg);
--color-tertiary-500: oklch(73.8% 0.145 210deg);
--color-tertiary-600: oklch(64.2% 0.126 214deg);
--color-tertiary-700: oklch(55.2% 0.107 218deg);
--color-tertiary-800: oklch(46.1% 0.087 221deg);
--color-tertiary-900: oklch(38.7% 0.07 225deg);
--color-tertiary-950: oklch(29.1% 0.053 229deg);
--color-tertiary-contrast-dark: var(--color-tertiary-950);
--color-tertiary-contrast-light: var(--color-tertiary-50);
--color-tertiary-contrast-50: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-100: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-200: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-300: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-400: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-500: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-600: var(--color-tertiary-contrast-light);
--color-tertiary-contrast-700: var(--color-tertiary-contrast-light);
--color-tertiary-contrast-800: var(--color-tertiary-contrast-light);
--color-tertiary-contrast-900: var(--color-tertiary-contrast-light);
--color-tertiary-contrast-950: var(--color-tertiary-contrast-light);
--color-success-50: oklch(97.9% 0.021 166deg);
--color-success-100: oklch(95.1% 0.05 167deg);
--color-success-200: oklch(90.5% 0.09 166deg);
--color-success-300: oklch(84.2% 0.137 165deg);
--color-success-400: oklch(76.5% 0.146 164deg);
--color-success-500: oklch(69.6% 0.135 163deg);
--color-success-600: oklch(59.6% 0.115 163deg);
--color-success-700: oklch(50.8% 0.096 164deg);
--color-success-800: oklch(42.7% 0.077 165deg);
--color-success-900: oklch(35.4% 0.061 166deg);
--color-success-950: oklch(24.5% 0.041 168deg);
--color-success-contrast-dark: var(--color-success-950);
--color-success-contrast-light: var(--color-success-50);
--color-success-contrast-50: var(--color-success-contrast-dark);
--color-success-contrast-100: var(--color-success-contrast-dark);
--color-success-contrast-200: var(--color-success-contrast-dark);
--color-success-contrast-300: var(--color-success-contrast-dark);
--color-success-contrast-400: var(--color-success-contrast-dark);
--color-success-contrast-500: var(--color-success-contrast-dark);
--color-success-contrast-600: var(--color-success-contrast-light);
--color-success-contrast-700: var(--color-success-contrast-light);
--color-success-contrast-800: var(--color-success-contrast-light);
--color-success-contrast-900: var(--color-success-contrast-light);
--color-success-contrast-950: var(--color-success-contrast-light);
--color-warning-50: oklch(98.7% 0.022 95deg);
--color-warning-100: oklch(96.2% 0.05 93deg);
--color-warning-200: oklch(92.5% 0.095 90deg);
--color-warning-300: oklch(87.9% 0.145 86deg);
--color-warning-400: oklch(82.8% 0.168 81deg);
--color-warning-500: oklch(76.9% 0.164 74deg);
--color-warning-600: oklch(66.6% 0.149 66deg);
--color-warning-700: oklch(56.3% 0.13 60deg);
--color-warning-800: oklch(47.8% 0.111 56deg);
--color-warning-900: oklch(40.5% 0.094 53deg);
--color-warning-950: oklch(28.2% 0.066 49deg);
--color-warning-contrast-dark: var(--color-warning-950);
--color-warning-contrast-light: var(--color-warning-50);
--color-warning-contrast-50: var(--color-warning-contrast-dark);
--color-warning-contrast-100: var(--color-warning-contrast-dark);
--color-warning-contrast-200: var(--color-warning-contrast-dark);
--color-warning-contrast-300: var(--color-warning-contrast-dark);
--color-warning-contrast-400: var(--color-warning-contrast-dark);
--color-warning-contrast-500: var(--color-warning-contrast-dark);
--color-warning-contrast-600: var(--color-warning-contrast-light);
--color-warning-contrast-700: var(--color-warning-contrast-light);
--color-warning-contrast-800: var(--color-warning-contrast-light);
--color-warning-contrast-900: var(--color-warning-contrast-light);
--color-warning-contrast-950: var(--color-warning-contrast-light);
--color-error-50: oklch(97.1% 0.014 17deg);
--color-error-100: oklch(93.7% 0.032 18deg);
--color-error-200: oklch(88.5% 0.062 19deg);
--color-error-300: oklch(81.4% 0.104 21deg);
--color-error-400: oklch(71.2% 0.164 24deg);
--color-error-500: oklch(63.7% 0.208 25deg);
--color-error-600: oklch(57.7% 0.204 26deg);
--color-error-700: oklch(50.5% 0.182 27deg);
--color-error-800: oklch(44.4% 0.156 27deg);
--color-error-900: oklch(39.6% 0.129 28deg);
--color-error-950: oklch(25.8% 0.082 28deg);
--color-error-contrast-dark: var(--color-error-950);
--color-error-contrast-light: var(--color-error-50);
--color-error-contrast-50: var(--color-error-contrast-dark);
--color-error-contrast-100: var(--color-error-contrast-dark);
--color-error-contrast-200: var(--color-error-contrast-dark);
--color-error-contrast-300: var(--color-error-contrast-dark);
--color-error-contrast-400: var(--color-error-contrast-dark);
--color-error-contrast-500: var(--color-error-contrast-light);
--color-error-contrast-600: var(--color-error-contrast-light);
--color-error-contrast-700: var(--color-error-contrast-light);
--color-error-contrast-800: var(--color-error-contrast-light);
--color-error-contrast-900: var(--color-error-contrast-light);
--color-error-contrast-950: var(--color-error-contrast-light);
/* Surface: slate-based AMCS dark shell */
--color-surface-50: oklch(98.4% 0.003 247.86deg);
--color-surface-100: oklch(96.8% 0.007 247.9deg);
--color-surface-200: oklch(92.9% 0.013 255.51deg);
--color-surface-300: oklch(86.9% 0.022 252.89deg);
--color-surface-400: oklch(70.4% 0.04 256.79deg);
--color-surface-500: oklch(55.4% 0.046 257.42deg);
--color-surface-600: oklch(44.6% 0.043 257.28deg);
--color-surface-700: oklch(37.2% 0.044 257.29deg);
--color-surface-800: oklch(27.9% 0.041 260.03deg);
--color-surface-900: oklch(20.8% 0.042 265.76deg);
--color-surface-950: oklch(12.9% 0.042 264.7deg);
--color-surface-contrast-dark: var(--color-surface-950);
--color-surface-contrast-light: var(--color-surface-50);
--color-surface-contrast-50: var(--color-surface-contrast-dark);
--color-surface-contrast-100: var(--color-surface-contrast-dark);
--color-surface-contrast-200: var(--color-surface-contrast-dark);
--color-surface-contrast-300: var(--color-surface-contrast-dark);
--color-surface-contrast-400: var(--color-surface-contrast-light);
--color-surface-contrast-500: var(--color-surface-contrast-light);
--color-surface-contrast-600: var(--color-surface-contrast-light);
--color-surface-contrast-700: var(--color-surface-contrast-light);
--color-surface-contrast-800: var(--color-surface-contrast-light);
--color-surface-contrast-900: var(--color-surface-contrast-light);
--color-surface-contrast-950: var(--color-surface-contrast-light);
}

View File

@@ -152,7 +152,10 @@ export const api = {
create: (name: string, description: string) =>
rsCall<import('./types').Project>('/api/rs/public/projects', 'create', {
data: { name, description }
})
}),
update: (id: string, data: { name?: string; description?: string }) =>
rsCall<void>(`/api/rs/public/projects/${id}`, 'update', { data }),
delete: (id: string) => rsCall<void>(`/api/rs/public/projects/${id}`, 'delete')
},
thoughts: {
list: (params: { q?: string; project_id?: string; limit?: number; offset?: number; include_archived?: boolean }) => {
@@ -232,7 +235,11 @@ export const api = {
archive: (id: string) =>
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', {
data: { archived_at: new Date().toISOString() }
})
}),
create: (data: { content: string; project_id?: string }) =>
rsCall<import('./types').Thought>('/api/rs/public/thoughts', 'create', { data }),
update: (id: string, data: { content?: string }) =>
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', { data })
},
skills: {
list: async (tag?: string) => {
@@ -243,7 +250,18 @@ export const api = {
});
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
},
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'delete')
get: async (id: string) => {
const row = await rsCall<Omit<import('./types').AgentSkill, 'tags'> & { tags?: unknown }>(
`/api/rs/public/agent_skills/${id}`,
'read'
);
return { ...row, tags: normalizeTags(row.tags) };
},
create: (data: { name: string; description?: string; content: string; tags?: string[] }) =>
rsCall<import('./types').AgentSkill>('/api/rs/public/agent_skills', 'create', { data }),
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'delete'),
update: (id: string, data: Partial<import('./types').AgentSkill>) =>
rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'update', { data })
},
guardrails: {
list: async (params?: { tag?: string; severity?: string }) => {
@@ -258,7 +276,9 @@ export const api = {
});
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
},
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'delete')
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'delete'),
update: (id: string, data: Partial<import('./types').AgentGuardrail>) =>
rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'update', { data })
},
files: {
list: (params?: { project_id?: string; thought_id?: string; kind?: string }) => {
@@ -313,6 +333,48 @@ export const api = {
dry_run: input?.dry_run ?? false
})
},
plans: {
list: async (params?: { status?: string; priority?: string; project_id?: string; limit?: number }) => {
const filters: ResolveSpecFilter[] = [];
if (params?.status) filters.push({ column: 'status', operator: 'eq', value: params.status });
if (params?.priority) filters.push({ column: 'priority', operator: 'eq', value: params.priority });
if (params?.project_id) filters.push({ column: 'project_id', operator: 'eq', value: params.project_id });
const rows = await rsReadMany<Omit<import('./types').Plan, 'tags'> & { tags?: unknown }>('plans', {
filters,
limit: params?.limit ?? 500,
sort: [{ column: 'updated_at', direction: 'desc' }]
});
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
},
get: async (id: string) => {
const row = await rsCall<Omit<import('./types').Plan, 'tags'> & { tags?: unknown }>(
`/api/rs/public/plans/${id}`,
'read'
);
return { ...row, tags: normalizeTags(row.tags) };
},
create: (data: object) =>
rsCall<import('./types').Plan>('/api/rs/public/plans', 'create', { data }),
update: (id: string, data: Partial<import('./types').Plan>) =>
rsCall<void>(`/api/rs/public/plans/${id}`, 'update', { data }),
delete: (id: string) => rsCall<void>(`/api/rs/public/plans/${id}`, 'delete')
},
learnings: {
list: (params?: { limit?: number; offset?: number }) =>
rsReadMany<import('./types').Learning>('learnings', {
limit: params?.limit ?? 500,
...(params?.offset !== undefined ? { offset: params.offset } : {}),
sort: [{ column: 'created_at', direction: 'desc' }]
}),
get: (id: string) =>
rsCall<import('./types').Learning>(`/api/rs/public/learnings/${id}`, 'read'),
create: (data: object) =>
rsCall<import('./types').Learning>('/api/rs/public/learnings', 'create', { data }),
update: (id: string, data: Partial<import('./types').Learning>) =>
rsCall<void>(`/api/rs/public/learnings/${id}`, 'update', { data }),
delete: (id: string) => rsCall<void>(`/api/rs/public/learnings/${id}`, 'delete')
},
stats: async () => {
type StatsThoughtRow = {
metadata?: {

View File

@@ -1,4 +1,12 @@
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
@import '@warkypublic/svelix/css/tailwind-source.css';
@import './amcs.theme.css';
@import '@skeletonlabs/skeleton';
@import '@skeletonlabs/skeleton-svelte';
@import '@skeletonlabs/skeleton/themes/cerberus';
@source '../node_modules/@skeletonlabs/skeleton-svelte/dist/**/*.{js,svelte,ts}';
:root {
color-scheme: dark;
@@ -14,3 +22,12 @@ body,
body {
margin: 0;
}
@layer base {
.input::placeholder,
.textarea::placeholder,
input::placeholder,
textarea::placeholder {
opacity: 0.65;
}
}

View File

@@ -1,34 +1,240 @@
<script lang="ts">
const { isOAuthCallback }: { isOAuthCallback: boolean } = $props();
import type { PublicStatusResponse } from "../../types";
type IntelligenceCard = {
id: string;
title: string;
accentClass: string;
summary: string;
detail: string;
tools: string[];
};
const {
isOAuthCallback,
data,
loading,
error,
}: {
isOAuthCallback: boolean;
data: PublicStatusResponse | null;
loading: boolean;
error: string;
} = $props();
const intelligenceCards: IntelligenceCard[] = [
{
id: "projects",
title: "Projects",
accentClass: "text-indigo-200",
summary:
"Named containers that scope memory and operations so retrieval stays focused on the right workstream.",
detail:
"Project context can be resolved explicitly or from active session scope depending on client behavior.",
tools: ["list_projects", "create_project", "get_project_context"],
},
{
id: "thoughts",
title: "Thoughts",
accentClass: "text-violet-200",
summary:
"Core memory records with metadata and links that power search, recall, summaries, and relationship traversal.",
detail:
"Thought capture can include metadata enrichment and link-based navigation for richer retrieval.",
tools: ["capture_thought", "search_thoughts", "related_thoughts"],
},
{
id: "skills",
title: "Skills",
accentClass: "text-cyan-200",
summary:
"Reusable agent instructions and capabilities that can be linked to a project and loaded with it.",
detail:
"Use project-linked skills to keep behavior consistent across sessions and assistants.",
tools: ["list_project_skills", "add_skill", "add_project_skill"],
},
{
id: "guardrails",
title: "Guardrails",
accentClass: "text-amber-200",
summary:
"Safety and policy constraints with severity levels that can be enforced globally or per project.",
detail:
"Guardrails provide stable operational boundaries for memory and tool usage behavior.",
tools: [
"list_project_guardrails",
"add_guardrail",
"add_project_guardrail",
],
},
{
id: "learnings",
title: "Learnings",
accentClass: "text-emerald-200",
summary:
"Curated records for durable lessons and decisions, separate from raw thoughts for cleaner review.",
detail:
"Structured fields such as status, priority, and confidence support operational follow-through.",
tools: ["add_learning", "list_learnings", "get_learning"],
},
{
id: "vector-metadata-build",
title: "Vector + Metadata Build",
accentClass: "text-fuchsia-200",
summary:
"Backfill and repair flows for embeddings and metadata so retrieval quality stays healthy over time.",
detail:
"Use dry runs for safe audits, then run updates to regenerate missing vectors or retry failed metadata.",
tools: [
"backfill_embeddings",
"reparse_thought_metadata",
"retry_failed_metadata",
],
},
];
let activeCard = $state("projects");
function setActiveCard(id: string) {
activeCard = id;
}
function handleCardKeydown(event: KeyboardEvent, id: string) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setActiveCard(id);
}
}
</script>
<div class="rounded-3xl border border-cyan-400/20 bg-slate-900/80 p-8 shadow-2xl shadow-slate-950/40">
<div class="inline-flex items-center gap-2 rounded-full border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-sm font-medium text-cyan-200">
<div
class="rounded-3xl border border-cyan-400/20 bg-slate-900/80 p-8 shadow-2xl shadow-slate-950/40"
>
<div
class="inline-flex items-center gap-2 rounded-full border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-sm font-medium text-cyan-200"
>
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
AMCS Control Interface
AMCS (Avalon Memory Control Service)
</div>
<h1 class="mt-6 text-4xl font-semibold tracking-tight text-white">
{#if isOAuthCallback}
Completing login
Completing login...
{:else}
Login
Avalon Memory Control Service
{/if}
</h1>
<p class="mt-3 max-w-2xl text-base leading-7 text-slate-300">
Origin-style operator access for the AMCS admin interface. ResolveSpec OAuth is the front door now,
not the old login shortcut.
AMCS is a Go MCP server for capturing project thoughts, semantic retrieval,
summaries, and linked memory workflows with Postgres + pgvector.
</p>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-400">
It stores durable memory for assistants, supports project scoping, and
exposes tools over MCP for capture, search, context recall, and structured
operations.
</p>
<div class="mt-8 grid gap-4 sm:grid-cols-2">
<div class="mt-8 grid gap-4 sm:grid-cols-3">
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Primary module</p>
<p class="mt-2 text-2xl font-semibold text-white">Projects</p>
<p class="mt-2 text-sm text-slate-400">Projects are the first real admin screen in this rollout.</p>
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">
Server status
</p>
{#if loading}
<p class="mt-2 text-lg font-semibold text-white">Loading…</p>
{:else if error}
<p class="mt-2 text-sm font-medium text-rose-300">Unavailable</p>
<p class="mt-2 text-xs text-rose-200/80">{error}</p>
{:else if data}
<p class="mt-2 text-2xl font-semibold text-white">
{data.connected_count}
</p>
<p class="mt-2 text-sm text-slate-400">
connected in {data.connected_window}
</p>
{#if data.entries.length > 0}
<p class="mt-2 text-xs text-slate-400">
Clients:
{#each data.entries.slice(0, 3) as client, idx}
<code class="text-slate-200">{client.key_id}</code>{idx <
Math.min(data.entries.length, 3) - 1
? ", "
: ""}
{/each}
{data.entries.length > 3 ? " …" : ""}
</p>
{/if}
<a
href="/status"
class="mt-3 inline-flex items-center rounded-lg border border-emerald-300/30 bg-emerald-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-emerald-100 transition hover:border-emerald-300/50 hover:bg-emerald-400/20"
>
Status Endpoint
</a>
{:else}
<p class="mt-2 text-sm text-slate-400">No status snapshot yet.</p>
{/if}
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">OAuth path</p>
<p class="mt-2 text-2xl font-semibold text-white">ResolveSpec</p>
<p class="mt-2 text-sm text-slate-400">Client registration, authorize, callback, token exchange.</p>
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">
Memory stack
</p>
<p class="mt-2 text-2xl font-semibold text-white">Postgres + pgvector</p>
<p class="mt-2 text-sm text-slate-400">
Semantic search with full-text fallback when vectors are missing.
</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">
Operator docs
</p>
<a
href="/llm"
class="mt-2 inline-flex items-center rounded-lg border border-cyan-300/30 bg-cyan-400/10 px-3 py-2 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/50 hover:bg-cyan-400/20"
>
Open LLM Instructions
</a>
<p class="mt-2 text-sm text-slate-400">
Tool behavior, workflows, and MCP guidance for assistants.
</p>
</div>
</div>
<div class="mt-6 rounded-2xl border border-white/10 bg-slate-950/35 p-5">
<h2 class="text-lg font-semibold text-white">Project intelligence model</h2>
<p class="mt-2 text-sm text-slate-400">
AMCS separates reusable behavior, safety constraints, and curated
knowledge so assistants can be guided consistently across sessions.
</p>
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each intelligenceCards as card}
<article
class={`rounded-xl border p-4 transition ${
activeCard === card.id
? "border-cyan-300/40 bg-cyan-400/[0.08] shadow-lg shadow-cyan-950/30"
: "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]"
}`}
role="button"
tabindex="0"
aria-pressed={activeCard === card.id}
onclick={() => setActiveCard(card.id)}
onkeydown={(event) => handleCardKeydown(event, card.id)}
>
<p class={`text-xs uppercase tracking-[0.16em] ${card.accentClass}`}>
{card.title}
</p>
<p class="mt-2 text-sm text-slate-300">{card.summary}</p>
{#if activeCard === card.id}
<p class="mt-2 text-xs text-slate-300/90">{card.detail}</p>
{/if}
<p class="mt-3 text-xs text-slate-400">
Tools:
{#each card.tools as tool, idx}
<code class="text-slate-200">{tool}</code>{idx <
card.tools.length - 1
? ", "
: ""}
{/each}
</p>
</article>
{/each}
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import LoginInfoPanel from './LoginInfoPanel.svelte';
import LoginPanel from './LoginPanel.svelte';
import type { PublicStatusResponse } from '../../types';
const {
isOAuthCallback,
@@ -8,6 +9,9 @@
authBusy,
authError,
authMessage,
statusData,
statusLoading,
statusError,
onlogin
}: {
isOAuthCallback: boolean;
@@ -15,20 +19,51 @@
authBusy: boolean;
authError: string;
authMessage: string;
statusData: PublicStatusResponse | null;
statusLoading: boolean;
statusError: string;
onlogin: (username: string, password: string) => void;
} = $props();
</script>
<main class="mx-auto flex min-h-screen max-w-6xl items-center px-4 py-10 sm:px-6 lg:px-8">
<section class="grid w-full gap-8 lg:grid-cols-[1.15fr_0.85fr]">
<LoginInfoPanel {isOAuthCallback} />
<LoginPanel
{isOAuthCallback}
{callbackBusy}
{authBusy}
{authError}
{authMessage}
{onlogin}
/>
<main class="mx-auto min-h-screen w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
<section class="flex min-h-[calc(100vh-4rem)] flex-col gap-8">
<LoginInfoPanel {isOAuthCallback} data={statusData} loading={statusLoading} error={statusError} />
<div class="mt-auto">
<LoginPanel
{isOAuthCallback}
{callbackBusy}
{authBusy}
{authError}
{authMessage}
{onlogin}
/>
</div>
<div class="mt-3 flex flex-wrap items-center justify-center gap-3 text-xs text-slate-400">
<a
href="/llms.txt"
class="inline-flex items-center rounded-md border border-white/10 bg-white/[0.03] px-2.5 py-1.5 transition hover:border-cyan-300/40 hover:text-cyan-100"
>
llms.txt
</a>
<a
href="/robots.txt"
class="inline-flex items-center rounded-md border border-white/10 bg-white/[0.03] px-2.5 py-1.5 transition hover:border-cyan-300/40 hover:text-cyan-100"
>
robots.txt
</a>
<a
href="/.well-known/oauth-authorization-server"
class="inline-flex items-center rounded-md border border-white/10 bg-white/[0.03] px-2.5 py-1.5 transition hover:border-cyan-300/40 hover:text-cyan-100"
>
OAuth Discovery
</a>
<a
href="/llm"
class="inline-flex items-center rounded-md border border-white/10 bg-white/[0.03] px-2.5 py-1.5 transition hover:border-cyan-300/40 hover:text-cyan-100"
>
LLM Docs
</a>
</div>
</section>
</main>

View File

@@ -28,7 +28,7 @@
{#if isOAuthCallback}
<h2 class="text-xl font-semibold text-white">Authorizing operator session</h2>
<p class="mt-2 text-sm leading-6 text-slate-400">
Finishing the ResolveSpec handshake and exchanging the returned code for an AMCS token.
Finishing the callback flow and exchanging the returned code for an AMCS token.
</p>
<div class="mt-6 rounded-2xl border border-cyan-400/20 bg-cyan-400/5 px-4 py-6 text-sm text-cyan-100">
@@ -42,7 +42,7 @@
</div>
{:else}
<h2 class="text-xl font-semibold text-white">Operator login</h2>
<p class="mt-1 text-sm text-slate-400">Authenticate with your ResolveSpec credentials.</p>
<p class="mt-1 text-sm text-slate-400">Authenticate to access the AMCS admin interface.</p>
<form class="mt-6 space-y-4" onsubmit={handleSubmit}>
<div>

View File

@@ -1,11 +1,162 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '../../api';
import type { StoredFile } from '../../types';
import {
ErrorBoundary,
FormerResolveSpecAPI,
GridlerFull,
TextAreaCtrl,
TextInputCtrl,
type GridlerColumn,
type GridlerContextMenuItem,
} from "@warkypublic/svelix";
import { adminGridTheme } from "../../gridTheme";
import { GlobalStateStore } from "../../shellState";
import type { StoredFile } from "../../types";
import FormerShell from "../shared/FormerShell.svelte";
let files = $state<StoredFile[]>([]);
let loading = $state(true);
let error = $state('');
type FileForm = {
id?: string;
name: string;
media_type: string;
kind: string;
project_id?: string;
thought_id?: string;
encoding?: string;
content?: string;
};
const FILE_PRIMARY_KEY = 'id';
let selectedFile = $state<StoredFile | null>(null);
let gridTotal = $state<number | null>(null);
let formOpened = $state(false);
let formRequest = $state<'insert' | 'update' | 'delete'>('update');
let formValues = $state<FileForm>({ name: '', media_type: '', kind: 'file', encoding: 'base64', content: '' });
let contextRow = $state<Record<string, unknown> | null>(null);
let refreshKey = $state(0);
const authToken = GlobalStateStore.getState().session.authToken ?? '';
const filesOnAPICall = $derived(FormerResolveSpecAPI({
authToken,
url: '/api/rs/public/stored_files'
}));
const columns: GridlerColumn[] = [
{ id: 'name', title: 'Name', dataKey: 'name', width: 280 },
{ id: 'media_type', title: 'Type', dataKey: 'media_type', width: 220 },
{ id: 'kind', title: 'Kind', dataKey: 'kind', width: 120 },
{ id: 'size_bytes', title: 'Size', dataKey: 'size_bytes', width: 120, format: 'number' },
{ id: 'thought_id', title: 'Thought', dataKey: 'thought_id', width: 220 },
{ id: 'project_id', title: 'Project', dataKey: 'project_id', width: 220 },
{ id: 'created_at', title: 'Uploaded', dataKey: 'created_at', width: 180, format: 'datetime' }
];
const menuItems: GridlerContextMenuItem[] = [
{ id: 'edit', label: 'Edit' },
{ id: 'delete', label: 'Delete' }
];
const filesDataSourceOptions = {
url: '/api/rs',
authToken: GlobalStateStore.getState().session.authToken,
schema: 'public',
entity: 'stored_files',
uniqueID: FILE_PRIMARY_KEY,
hotfields: [FILE_PRIMARY_KEY, 'guid', 'project_id', 'thought_id'],
columns: ['id', 'guid', 'name', 'media_type', 'kind', 'size_bytes', 'created_at', 'updated_at', 'project_id', 'thought_id'],
sort: [{ column: 'created_at', direction: 'desc' }]
} as unknown as {
url: string;
authToken?: string;
schema: string;
entity: string;
uniqueID: string;
hotfields: string[];
columns: string[];
};
function normalizeFile(rowData: Record<string, unknown>): StoredFile {
return {
id: String(rowData.id ?? ''),
name: String(rowData.name ?? ''),
media_type: String(rowData.media_type ?? ''),
kind: String(rowData.kind ?? ''),
size_bytes: Number(rowData.size_bytes ?? 0),
thought_id: typeof rowData.thought_id === 'string' ? rowData.thought_id : undefined,
project_id: typeof rowData.project_id === 'string' ? rowData.project_id : undefined,
sha256: String(rowData.sha256 ?? ''),
created_at: String(rowData.created_at ?? ''),
updated_at: String(rowData.updated_at ?? '')
};
}
function normalizeFileRecordForFormer(data: Record<string, unknown>): FileForm {
return {
id: typeof data.id === 'string' || typeof data.id === 'number' ? String(data.id) : undefined,
name: typeof data.name === 'string' ? data.name : '',
media_type: typeof data.media_type === 'string' ? data.media_type : '',
kind: typeof data.kind === 'string' ? data.kind : 'file',
project_id: typeof data.project_id === 'string' && data.project_id ? data.project_id : undefined,
thought_id: typeof data.thought_id === 'string' && data.thought_id ? data.thought_id : undefined,
encoding: typeof data.encoding === 'string' ? data.encoding : 'base64',
content: typeof data.content === 'string' ? data.content : ''
};
}
async function loadFileFromRow(rowData: Record<string, unknown>): Promise<FileForm> {
const id = String(rowData[FILE_PRIMARY_KEY] ?? '');
const data = await filesOnAPICall('read', 'update', undefined, id) as Record<string, unknown>;
return normalizeFileRecordForFormer(data);
}
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
selectedFile = rowData ? normalizeFile(rowData) : null;
}
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
contextRow = rowData ?? null;
}
async function onMenuItemSelect(item: GridlerContextMenuItem) {
if (!contextRow) return;
formValues = normalizeFileRecordForFormer(contextRow);
formRequest = item.id === 'delete' ? 'delete' : 'update';
formOpened = true;
}
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
if (!rowData) return;
contextRow = rowData;
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
}
function onGridEvent(
type: string,
_item?: unknown,
_column?: unknown,
_coords?: unknown,
detail?: Record<string, unknown>
) {
if (type !== 'page_loaded' && type !== 'load') return;
const total = detail?.total;
if (typeof total === 'number') gridTotal = total;
}
function normalizeFileForm(data: FileForm): Record<string, unknown> {
return {
name: data.name.trim(),
media_type: data.media_type.trim(),
kind: data.kind.trim(),
project_id: data.project_id?.trim() || undefined,
thought_id: data.thought_id?.trim() || undefined
};
}
async function handleFileSaved() {
formOpened = false;
if (contextRow) {
selectedFile = normalizeFile(contextRow);
}
refreshKey += 1;
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
@@ -13,80 +164,145 @@
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
function formatDate(value: string) {
function formatDate(value?: string): string {
if (!value) return '—';
return new Date(value).toLocaleString();
}
async function load() {
loading = true;
error = '';
try {
files = await api.files.list();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load files';
} finally {
loading = false;
}
}
onMount(load);
</script>
<div class="space-y-4">
<div class="flex items-end justify-between">
<div class="space-y-4 w-full">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Files</h2>
<p class="mt-1 text-sm text-slate-400">{files.length} file{files.length !== 1 ? 's' : ''}</p>
<p class="mt-1 text-sm text-slate-400">
{#if gridTotal === null}
Server-backed grid
{:else}
{gridTotal} file{gridTotal !== 1 ? 's' : ''}
{/if}
</p>
</div>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={load}
>Refresh</button>
</div>
{#if error}
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
{/if}
{#if loading}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
{:else if files.length === 0}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No files stored.</div>
{:else}
<div class="overflow-hidden rounded-2xl border border-white/10">
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
<tr>
<th class="px-4 py-3 text-left font-medium">Name</th>
<th class="px-4 py-3 text-left font-medium">Type</th>
<th class="px-4 py-3 text-left font-medium">Kind</th>
<th class="px-4 py-3 text-right font-medium">Size</th>
<th class="px-4 py-3 text-right font-medium">Uploaded</th>
<th class="px-4 py-3 text-right font-medium">Download</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each files as f}
<tr class="hover:bg-white/[0.03]">
<td class="max-w-xs truncate px-4 py-3 font-medium text-white">{f.name}</td>
<td class="px-4 py-3 text-slate-400 text-xs">{f.media_type}</td>
<td class="px-4 py-3">
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-300">{f.kind || '—'}</span>
</td>
<td class="px-4 py-3 text-right tabular-nums text-slate-200">{formatBytes(f.size_bytes)}</td>
<td class="px-4 py-3 text-right text-slate-400">{formatDate(f.created_at)}</td>
<td class="px-4 py-3 text-right">
<a
href={`/files/${f.id}`}
target="_blank"
rel="noreferrer"
class="text-xs text-cyan-400 hover:text-cyan-300"
></a>
</td>
</tr>
{/each}
</tbody>
</table>
<div class="flex flex-col gap-4">
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
{#key refreshKey}
<ErrorBoundary namespace="FilesGridlerFull">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={560}
width="100%"
pageSize={40}
dataSource="resolvespec"
dataSourceOptions={filesDataSourceOptions}
serverSideSearch={true}
searchColumns={['name', 'media_type', 'kind', 'project_id', 'thought_id']}
{menuItems}
{onGridEvent}
{onRowClick}
{onRowDblClick}
{onRowContextMenu}
{onMenuItemSelect}
/>
</ErrorBoundary>
{/key}
</div>
{/if}
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
<h3 class="text-sm font-semibold text-white">File Inspector</h3>
{#if !selectedFile}
<p class="mt-3 text-sm text-slate-500">Select a file row to inspect metadata.</p>
{:else}
<div class="mt-3 space-y-3 text-sm text-slate-300">
<p class="text-base font-semibold text-slate-100">{selectedFile.name}</p>
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
<p><strong class="text-slate-100">Type:</strong> {selectedFile.media_type}</p>
<p><strong class="text-slate-100">Kind:</strong> {selectedFile.kind || '—'}</p>
<p><strong class="text-slate-100">Size:</strong> {formatBytes(selectedFile.size_bytes)}</p>
<p><strong class="text-slate-100">Thought:</strong> {selectedFile.thought_id || '—'}</p>
<p><strong class="text-slate-100">Project:</strong> {selectedFile.project_id || '—'}</p>
<p><strong class="text-slate-100">Uploaded:</strong> {formatDate(selectedFile.created_at)}</p>
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedFile.updated_at)}</p>
</div>
<div class="flex items-center gap-3">
<a
href={`/files/${selectedFile.id}`}
target="_blank"
rel="noreferrer"
class="text-xs text-cyan-300 hover:text-cyan-200"
>Download</a>
</div>
</div>
{/if}
</aside>
</div>
</div>
<ErrorBoundary namespace="FilesFormer">
<FormerShell
bind:opened={formOpened}
bind:values={formValues}
bind:request={formRequest}
title={formRequest === 'update' ? 'Edit File' : 'Delete File'}
uniqueKeyField={FILE_PRIMARY_KEY}
onAPICall={filesOnAPICall}
afterGet={async (data) => normalizeFileRecordForFormer(data as Record<string, unknown>)}
beforeSave={normalizeFileForm}
afterSave={handleFileSaved}
onClose={() => { formOpened = false; }}
>
{#snippet children(state)}
<div class="space-y-4 p-4">
<TextInputCtrl
label="Name"
name="name"
required
disabled={state.request === 'delete'}
value={state.values?.name ?? ''}
onchange={(v) => state.setState('values', { ...state.values, name: v })}
/>
<TextInputCtrl
label="Media Type"
name="media_type"
required
disabled={state.request === 'delete'}
value={state.values?.media_type ?? ''}
onchange={(v) => state.setState('values', { ...state.values, media_type: v })}
/>
<TextInputCtrl
label="Kind"
name="kind"
disabled={state.request === 'delete'}
value={state.values?.kind ?? ''}
onchange={(v) => state.setState('values', { ...state.values, kind: v })}
/>
<TextInputCtrl
label="Project ID"
name="project_id"
disabled={state.request === 'delete'}
value={state.values?.project_id ?? ''}
onchange={(v) => state.setState('values', { ...state.values, project_id: v || undefined })}
/>
<TextInputCtrl
label="Thought ID"
name="thought_id"
disabled={state.request === 'delete'}
value={state.values?.thought_id ?? ''}
onchange={(v) => state.setState('values', { ...state.values, thought_id: v || undefined })}
/>
{#if state.request !== 'delete'}
<TextAreaCtrl
label="Content"
name="content"
rows={4}
disabled={true}
value={state.values?.content ?? ''}
onchange={() => {}}
/>
{/if}
</div>
{/snippet}
</FormerShell>
</ErrorBoundary>

View File

@@ -1,103 +1,407 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '../../api';
import type { AgentGuardrail } from '../../types';
import {
ErrorBoundary,
FormerResolveSpecAPI,
GridlerFull,
NativeSelectCtrl,
TextInputCtrl,
type GridlerColumn,
type GridlerContextMenuItem,
} from "@warkypublic/svelix";
import { adminGridTheme } from "../../gridTheme";
import { GlobalStateStore } from "../../shellState";
import type { AgentGuardrail } from "../../types";
import FormerShell from "../shared/FormerShell.svelte";
import ContentEditorField from "../shared/ContentEditorField.svelte";
const severityColour: Record<string, string> = {
low: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-200',
medium: 'border-amber-400/20 bg-amber-400/10 text-amber-200',
high: 'border-orange-400/20 bg-orange-400/10 text-orange-200',
critical: 'border-rose-400/20 bg-rose-400/10 text-rose-200'
type GuardrailForm = {
id?: string;
name: string;
description: string;
content: string;
severity: string;
tags: string;
};
let guardrails = $state<AgentGuardrail[]>([]);
let loading = $state(true);
let error = $state('');
let busy = $state<string | null>(null);
const GUARDRAIL_PRIMARY_KEY = 'id';
async function load() {
loading = true;
error = '';
try {
guardrails = await api.guardrails.list();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load guardrails';
} finally {
loading = false;
const severityClasses: Record<string, string> = {
low: 'bg-emerald-400/10 text-emerald-200',
medium: 'bg-amber-400/10 text-amber-200',
high: 'bg-orange-400/10 text-orange-200',
critical: 'bg-rose-400/10 text-rose-200'
};
let selectedGuardrail = $state<AgentGuardrail | null>(null);
let gridTotal = $state<number | null>(null);
let formOpened = $state(false);
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
let formValues = $state<GuardrailForm>({
name: '',
description: '',
content: '',
severity: 'medium',
tags: ''
});
let editorOpened = $state(false);
let editorValues = $state<{ id?: string; content: string }>({ content: '' });
let contextRow = $state<Record<string, unknown> | null>(null);
let refreshKey = $state(0);
const authToken = GlobalStateStore.getState().session.authToken ?? '';
const guardrailOnAPICall = $derived(FormerResolveSpecAPI({
authToken,
url: '/api/rs/public/agent_guardrails'
}));
const columns: GridlerColumn[] = [
{ id: 'name', title: 'Name', dataKey: 'name', width: 240 },
{ id: 'description', title: 'Description', dataKey: 'description', width: 320 },
{ id: 'severity', title: 'Severity', dataKey: 'severity', width: 120 },
{ id: 'tags', title: 'Tags', dataKey: 'tags', width: 220 },
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 180, format: 'datetime' },
{ id: 'updated_at', title: 'Updated', dataKey: 'updated_at', width: 180, format: 'datetime' }
];
const menuItems: GridlerContextMenuItem[] = [
{ id: 'add', label: 'Add' },
{ id: 'edit', label: 'Edit' },
{ id: 'edit_content', label: 'Edit Content' },
{ id: 'delete', label: 'Delete' }
];
const guardrailsDataSourceOptions = {
url: '/api/rs',
authToken: GlobalStateStore.getState().session.authToken,
schema: 'public',
entity: 'agent_guardrails',
uniqueID: GUARDRAIL_PRIMARY_KEY,
hotfields: [GUARDRAIL_PRIMARY_KEY],
sort: [{ column: 'created_at', direction: 'desc' }]
} as unknown as {
url: string;
authToken?: string;
schema: string;
entity: string;
uniqueID: string;
hotfields: string[];
};
function normalizeTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
if (typeof value !== 'string' || !value.trim()) return [];
const trimmed = value.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
return trimmed
.slice(1, -1)
.split(',')
.map((tag) => tag.trim().replace(/^"(.*)"$/, '$1'))
.filter(Boolean);
}
return trimmed.split(',').map((tag) => tag.trim()).filter(Boolean);
}
async function remove(id: string, name: string) {
if (!confirm(`Delete guardrail "${name}"?`)) return;
busy = id;
try {
await api.guardrails.delete(id);
await load();
} catch (e) {
error = e instanceof Error ? e.message : 'Delete failed';
} finally {
busy = null;
}
function normalizeGuardrail(rowData: Record<string, unknown>): AgentGuardrail {
return {
id: String(rowData.id ?? ''),
name: String(rowData.name ?? ''),
description: String(rowData.description ?? ''),
content: String(rowData.content ?? ''),
severity: (typeof rowData.severity === 'string' ? rowData.severity : 'medium') as AgentGuardrail['severity'],
tags: normalizeTags(rowData.tags),
created_at: String(rowData.created_at ?? ''),
updated_at: String(rowData.updated_at ?? '')
};
}
onMount(load);
function toGuardrailForm(guardrail: AgentGuardrail): GuardrailForm {
return {
id: guardrail.id,
name: guardrail.name,
description: guardrail.description,
content: guardrail.content,
severity: guardrail.severity,
tags: guardrail.tags.join(', ')
};
}
function normalizeGuardrailRecordForFormer(data: Record<string, unknown>): GuardrailForm {
return {
id: data.id != null ? String(data.id) : undefined,
name: typeof data.name === 'string' ? data.name : '',
description: typeof data.description === 'string' ? data.description : '',
content: typeof data.content === 'string' ? data.content : '',
severity: typeof data.severity === 'string' ? data.severity : 'medium',
tags: normalizeTags(data.tags).join(', ')
};
}
async function loadGuardrailFromRow(rowData: Record<string, unknown>): Promise<AgentGuardrail> {
const id = String(rowData[GUARDRAIL_PRIMARY_KEY] ?? '');
return await guardrailOnAPICall('read', 'update', undefined, id) as AgentGuardrail;
}
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
selectedGuardrail = rowData ? normalizeGuardrail(rowData) : null;
}
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
contextRow = rowData ?? null;
}
async function onMenuItemSelect(item: GridlerContextMenuItem) {
if (item.id === 'add') {
formValues = { name: '', description: '', content: '', severity: 'medium', tags: '' };
formRequest = 'insert';
formOpened = true;
return;
}
if (!contextRow) return;
if (item.id === 'edit_content') {
const guardrail = normalizeGuardrail(contextRow);
selectedGuardrail = guardrail;
editorValues = { id: guardrail.id, content: guardrail.content };
editorOpened = true;
return;
}
const guardrail = normalizeGuardrail(contextRow);
formValues = toGuardrailForm(guardrail);
formRequest = item.id === 'delete' ? 'delete' : 'update';
formOpened = true;
}
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
if (!rowData) return;
contextRow = rowData;
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
}
function onGridEvent(
type: string,
_item?: unknown,
_column?: unknown,
_coords?: unknown,
detail?: Record<string, unknown>
) {
if (type !== 'page_loaded' && type !== 'load') return;
const total = detail?.total;
if (typeof total === 'number') gridTotal = total;
}
function normalizeGuardrailForm(data: GuardrailForm): Record<string, unknown> {
return {
name: data.name.trim(),
description: data.description.trim(),
content: data.content,
severity: data.severity,
tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean)
};
}
async function handleGuardrailSaved() {
formOpened = false;
if (contextRow?.id) {
selectedGuardrail = await loadGuardrailFromRow(contextRow);
}
refreshKey += 1;
}
async function handleGuardrailEditorSaved() {
editorOpened = false;
if (editorValues.id) {
selectedGuardrail = await guardrailOnAPICall('read', 'update', undefined, editorValues.id) as AgentGuardrail;
editorValues = { id: selectedGuardrail.id, content: selectedGuardrail.content };
}
refreshKey += 1;
}
function formatDate(value?: string): string {
if (!value) return '—';
return new Date(value).toLocaleString();
}
</script>
<div class="space-y-4">
<div class="flex items-end justify-between">
<div class="space-y-4 w-full">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Guardrails</h2>
<p class="mt-1 text-sm text-slate-400">{guardrails.length} guardrail{guardrails.length !== 1 ? 's' : ''}</p>
<p class="mt-1 text-sm text-slate-400">
{#if gridTotal === null}
Server-backed grid
{:else}
{gridTotal} guardrail{gridTotal !== 1 ? 's' : ''}
{/if}
</p>
</div>
<div class="flex items-center gap-3">
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={() => {
formValues = { name: '', description: '', content: '', severity: 'medium', tags: '' };
formRequest = 'insert';
formOpened = true;
}}
>New Guardrail</button>
</div>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={load}
>Refresh</button>
</div>
{#if error}
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
{/if}
<div class="flex flex-col gap-4">
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
{#key refreshKey}
<ErrorBoundary namespace="GuardrailsGridlerFull">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={560}
width="100%"
pageSize={40}
dataSource="resolvespec"
dataSourceOptions={guardrailsDataSourceOptions}
serverSideSearch={true}
searchColumns={['name', 'description', 'content', 'severity', 'tags']}
{menuItems}
{onGridEvent}
{onRowClick}
{onRowDblClick}
{onRowContextMenu}
{onMenuItemSelect}
/>
</ErrorBoundary>
{/key}
</div>
{#if loading}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
{:else if guardrails.length === 0}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No guardrails registered.</div>
{:else}
<div class="space-y-3">
{#each guardrails as g}
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<p class="font-semibold text-white">{g.name}</p>
<span class={`rounded-full border px-2 py-0.5 text-xs font-medium ${severityColour[g.severity] ?? severityColour.medium}`}>
{g.severity}
</span>
</div>
{#if g.description}
<p class="mt-1 text-sm text-slate-400">{g.description}</p>
{/if}
{#if g.tags?.length}
<div class="mt-2 flex flex-wrap gap-1">
{#each g.tags as tag}
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">{tag}</span>
{/each}
</div>
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white">Guardrail Inspector</h3>
{#if selectedGuardrail}
<button
class="text-xs text-cyan-300 hover:text-cyan-200"
onclick={() => {
const guardrail = selectedGuardrail;
if (!guardrail) return;
editorValues = { id: guardrail.id, content: guardrail.content };
editorOpened = true;
}}
>Edit Content</button>
{/if}
</div>
{#if !selectedGuardrail}
<p class="mt-3 text-sm text-slate-500">Select a guardrail row to inspect its rule content.</p>
{:else}
<div class="mt-3 space-y-3 text-sm text-slate-300">
<div class="flex flex-wrap items-center gap-2">
<p class="text-base font-semibold text-slate-100">{selectedGuardrail.name}</p>
<span class={`inline-flex items-center rounded-lg px-2.5 py-0.5 text-xs font-medium ${severityClasses[selectedGuardrail.severity] ?? severityClasses.medium}`}>
{selectedGuardrail.severity}
</span>
</div>
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
<p><strong class="text-slate-100">Description:</strong> {selectedGuardrail.description || '—'}</p>
<p><strong class="text-slate-100">Created:</strong> {formatDate(selectedGuardrail.created_at)}</p>
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedGuardrail.updated_at)}</p>
</div>
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Content</p>
<p class="mt-2 whitespace-pre-wrap text-slate-300">{selectedGuardrail.content}</p>
</div>
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Tags</p>
<div class="mt-2 flex flex-wrap gap-1.5">
{#if selectedGuardrail.tags.length}
{#each selectedGuardrail.tags as tag}
<span class="rounded-md bg-white/10 px-2 py-0.5 text-xs text-slate-300">{tag}</span>
{/each}
{:else}
<span class="text-slate-500">No tags</span>
{/if}
</div>
<button
class="shrink-0 text-xs text-rose-400 hover:text-rose-300 disabled:opacity-40"
onclick={() => remove(g.id, g.name)}
disabled={busy === g.id}
>Delete</button>
</div>
<details class="mt-3">
<summary class="cursor-pointer text-xs text-slate-500 hover:text-slate-300">View content</summary>
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{g.content}</pre>
</details>
</div>
{/each}
</div>
{/if}
{/if}
</aside>
</div>
</div>
<ErrorBoundary namespace="GuardrailsEditorFormer">
<FormerShell
bind:opened={editorOpened}
bind:values={editorValues}
request="update"
title="Edit Guardrail Content"
uniqueKeyField={GUARDRAIL_PRIMARY_KEY}
width="min(96vw, 90rem)"
onAPICall={guardrailOnAPICall}
beforeSave={(data) => ({ content: data.content })}
afterSave={handleGuardrailEditorSaved}
onClose={() => { editorOpened = false; }}
>
{#snippet children(state)}
<ContentEditorField
filename="guardrail.md"
value={state.values?.content ?? ''}
onchange={(v) => state.setState('values', { ...state.values, content: v })}
/>
{/snippet}
</FormerShell>
</ErrorBoundary>
<ErrorBoundary namespace="GuardrailsFormer">
<FormerShell
bind:opened={formOpened}
bind:values={formValues}
bind:request={formRequest}
title={formRequest === 'insert' ? 'New Guardrail' : formRequest === 'update' ? 'Edit Guardrail' : 'Delete Guardrail'}
uniqueKeyField={GUARDRAIL_PRIMARY_KEY}
onAPICall={guardrailOnAPICall}
afterGet={async (data) => normalizeGuardrailRecordForFormer(data as Record<string, unknown>)}
beforeSave={normalizeGuardrailForm}
afterSave={handleGuardrailSaved}
onClose={() => { formOpened = false; }}
>
{#snippet children(state)}
<div class="space-y-4 p-4">
<TextInputCtrl
label="Name"
name="name"
required
disabled={state.request === 'delete'}
value={state.values?.name ?? ''}
onchange={(v) => state.setState('values', { ...state.values, name: v })}
/>
<TextInputCtrl
label="Description"
name="description"
disabled={state.request === 'delete'}
value={state.values?.description ?? ''}
onchange={(v) => state.setState('values', { ...state.values, description: v })}
/>
<ContentEditorField
filename="guardrail.md"
value={state.values?.content ?? ''}
disabled={state.request === 'delete'}
onchange={(v) => state.setState('values', { ...state.values, content: v })}
/>
<NativeSelectCtrl
label="Severity"
name="severity"
disabled={state.request === 'delete'}
value={state.values?.severity ?? 'medium'}
options={['low', 'medium', 'high', 'critical']}
onchange={(v) => state.setState('values', { ...state.values, severity: v })}
/>
<TextInputCtrl
label="Tags"
name="tags"
placeholder="comma-separated"
disabled={state.request === 'delete'}
value={state.values?.tags ?? ''}
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
/>
</div>
{/snippet}
</FormerShell>
</ErrorBoundary>

View File

@@ -1,18 +1,163 @@
<script lang="ts">
import { GridlerFull, type GridlerColumn } from "@warkypublic/svelix";
import { GlobalStateStore } from "../../shellState";
import {
ErrorBoundary,
FormerResolveSpecAPI,
GridlerFull,
NativeSelectCtrl,
SwitchCtrl,
TextInputCtrl,
type GridlerColumn,
type GridlerContextMenuItem,
} from "@warkypublic/svelix";
import FormerShell from "../shared/FormerShell.svelte";
import { adminGridTheme } from "../../gridTheme";
import { GlobalStateStore } from "../../shellState";
import type { Learning } from "../../types";
import ContentEditorField from "../shared/ContentEditorField.svelte";
type LearningForm = {
id?: string;
summary: string;
details: string;
category: string;
area: string;
status: string;
priority: string;
confidence: string;
action_required: boolean;
source_type?: string;
source_ref?: string;
tags?: string;
};
const LEARNING_PRIMARY_KEY = 'id';
let selectedLearning = $state<Learning | null>(null);
let gridTotal = $state<number | null>(null);
let formOpened = $state(false);
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
let formValues = $state<LearningForm>({ summary: '', details: '', category: '', area: '', status: 'active', priority: 'medium', confidence: 'medium', action_required: false });
let editorOpened = $state(false);
let editorValues = $state<{ id?: string; details: string }>({ details: '' });
let contextRow = $state<Record<string, unknown> | null>(null);
let refreshKey = $state(0);
const authToken = GlobalStateStore.getState().session.authToken ?? '';
const learningOnAPICall = $derived(FormerResolveSpecAPI({
authToken,
url: '/api/rs/public/learnings'
}));
const menuItems: GridlerContextMenuItem[] = [
{ id: 'add', label: 'Add' },
{ id: 'edit', label: 'Edit' },
{ id: 'edit_content', label: 'Edit Content' },
{ id: 'delete', label: 'Delete' },
];
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
contextRow = rowData ?? null;
}
function toLearningForm(learning: Learning): LearningForm {
return {
id: learning.id,
summary: learning.summary,
details: learning.details,
category: learning.category,
area: learning.area,
status: learning.status,
priority: learning.priority,
confidence: learning.confidence,
action_required: learning.action_required,
source_type: learning.source_type,
source_ref: learning.source_ref,
tags: learning.tags
};
}
function normalizeLearningRecordForFormer(data: Record<string, unknown>): LearningForm {
return {
id: data.id != null ? String(data.id) : undefined,
summary: typeof data.summary === 'string' ? data.summary : '',
details: typeof data.details === 'string' ? data.details : '',
category: typeof data.category === 'string' ? data.category : '',
area: typeof data.area === 'string' ? data.area : '',
status: typeof data.status === 'string' ? data.status : 'active',
priority: typeof data.priority === 'string' ? data.priority : 'medium',
confidence: typeof data.confidence === 'string' ? data.confidence : 'medium',
action_required: Boolean(data.action_required),
source_type: typeof data.source_type === 'string' && data.source_type ? data.source_type : undefined,
source_ref: typeof data.source_ref === 'string' && data.source_ref ? data.source_ref : undefined,
tags: typeof data.tags === 'string' ? data.tags : undefined
};
}
async function loadLearningFromRow(rowData: Record<string, unknown>): Promise<Learning> {
const id = String(rowData[LEARNING_PRIMARY_KEY] ?? '');
return await learningOnAPICall('read', 'update', undefined, id) as Learning;
}
async function onMenuItemSelect(item: GridlerContextMenuItem) {
if (item.id === 'add') {
formValues = { summary: '', details: '', category: '', area: '', status: 'active', priority: 'medium', confidence: 'medium', action_required: false };
formRequest = 'insert';
formOpened = true;
return;
}
if (!contextRow) return;
if (item.id === 'edit_content') {
const learning = normalizeLearning(contextRow);
selectedLearning = learning;
editorValues = { id: learning.id, details: learning.details };
editorOpened = true;
return;
}
const learning = normalizeLearning(contextRow);
formValues = toLearningForm(learning);
formRequest = item.id === 'delete' ? 'delete' : 'update';
formOpened = true;
}
function normalizeLearningForm(data: LearningForm): Record<string, unknown> {
return {
summary: data.summary.trim(),
details: data.details,
category: data.category.trim(),
area: data.area.trim(),
status: data.status,
priority: data.priority,
confidence: data.confidence,
action_required: data.action_required,
source_type: data.source_type?.trim() || undefined,
source_ref: data.source_ref?.trim() || undefined,
tags: data.tags?.trim() || undefined,
};
}
async function handleLearningSaved() {
formOpened = false;
if (contextRow?.id) {
selectedLearning = await loadLearningFromRow(contextRow);
}
refreshKey++;
}
async function handleLearningEditorSaved() {
editorOpened = false;
if (editorValues.id) {
selectedLearning = await learningOnAPICall('read', 'update', undefined, editorValues.id) as Learning;
editorValues = { id: selectedLearning.id, details: selectedLearning.details };
}
refreshKey++;
}
const learningsDataSourceOptions = {
url: "/api/rs",
authToken: GlobalStateStore.getState().session.authToken,
schema: "public",
entity: "learnings",
uniqueID: "id",
uniqueID: LEARNING_PRIMARY_KEY,
hotfields: [LEARNING_PRIMARY_KEY],
sort: [{ column: "created_at", direction: "desc" }],
} as unknown as {
url: string;
@@ -20,6 +165,7 @@
schema: string;
entity: string;
uniqueID: string;
hotfields: string[];
};
const columns: GridlerColumn[] = [
@@ -90,6 +236,12 @@
selectedLearning = normalizeLearning(rowData);
}
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
if (!rowData) return;
contextRow = rowData;
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
}
function onGridEvent(
type: string,
_item?: unknown,
@@ -120,29 +272,53 @@
{/if}
</p>
</div>
<div class="flex items-center gap-3">
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={() => { formValues = { summary: '', details: '', category: '', area: '', status: 'active', priority: 'medium', confidence: 'medium', action_required: false }; formRequest = 'insert'; formOpened = true; }}>New Learning</button
>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[1.6fr_1fr]">
<div class="flex flex-col gap-4">
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={560}
width="100%"
pageSize={40}
dataSource="resolvespec"
dataSourceOptions={learningsDataSourceOptions}
serverSideSearch={true}
searchColumns={["summary", "details", "category", "area", "status"]}
{onGridEvent}
{onRowClick}
/>
{#key refreshKey}
<ErrorBoundary namespace="LearningsGridlerFull">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={560}
width="100%"
pageSize={40}
dataSource="resolvespec"
dataSourceOptions={learningsDataSourceOptions}
serverSideSearch={true}
searchColumns={["summary", "details", "category", "area", "status"]}
{menuItems}
{onGridEvent}
{onRowClick}
{onRowDblClick}
{onRowContextMenu}
{onMenuItemSelect}
/>
</ErrorBoundary>
{/key}
</div>
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
<div class="flex items-start justify-between gap-3">
<h3 class="text-sm font-semibold text-white">Learning Inspector</h3>
{#if selectedLearning}
<button
class="text-xs text-cyan-300 hover:text-cyan-200"
onclick={() => {
const learning = selectedLearning;
if (!learning) return;
editorValues = { id: learning.id, details: learning.details };
editorOpened = true;
}}>Edit Content</button>
{/if}
</div>
{#if !selectedLearning}
@@ -217,3 +393,127 @@
</aside>
</div>
</div>
<ErrorBoundary namespace="LearningsEditorFormer">
<FormerShell
bind:opened={editorOpened}
bind:values={editorValues}
request="update"
title="Edit Learning Details"
uniqueKeyField={LEARNING_PRIMARY_KEY}
width="min(96vw, 90rem)"
onAPICall={learningOnAPICall}
beforeSave={(data) => ({ details: data.details })}
afterSave={handleLearningEditorSaved}
onClose={() => { editorOpened = false; }}
>
{#snippet children(state)}
<ContentEditorField
filename="learning.md"
value={state.values?.details ?? ''}
onchange={(v) => state.setState('values', { ...state.values, details: v })}
/>
{/snippet}
</FormerShell>
</ErrorBoundary>
<ErrorBoundary namespace="LearningsFormer">
<FormerShell
bind:opened={formOpened}
bind:values={formValues}
bind:request={formRequest}
title={formRequest === 'insert' ? 'New Learning' : formRequest === 'update' ? 'Edit Learning' : 'Delete Learning'}
uniqueKeyField={LEARNING_PRIMARY_KEY}
onAPICall={learningOnAPICall}
afterGet={async (data) => normalizeLearningRecordForFormer(data as Record<string, unknown>)}
beforeSave={normalizeLearningForm}
afterSave={handleLearningSaved}
onClose={() => { formOpened = false; }}
>
{#snippet children(state)}
<div class="space-y-4 p-4">
<TextInputCtrl
label="Summary"
name="summary"
required
disabled={state.request === 'delete'}
value={state.values?.summary ?? ''}
onchange={(v) => state.setState('values', { ...state.values, summary: v })}
/>
<ContentEditorField
filename="learning.md"
value={state.values?.details ?? ''}
disabled={state.request === 'delete'}
onchange={(v) => state.setState('values', { ...state.values, details: v })}
/>
<TextInputCtrl
label="Category"
name="category"
disabled={state.request === 'delete'}
value={state.values?.category ?? ''}
onchange={(v) => state.setState('values', { ...state.values, category: v })}
/>
<TextInputCtrl
label="Area"
name="area"
disabled={state.request === 'delete'}
value={state.values?.area ?? ''}
onchange={(v) => state.setState('values', { ...state.values, area: v })}
/>
<NativeSelectCtrl
label="Status"
name="status"
disabled={state.request === 'delete'}
value={state.values?.status ?? 'active'}
options={['active', 'completed', 'pending', 'archived']}
onchange={(v) => state.setState('values', { ...state.values, status: v })}
/>
<NativeSelectCtrl
label="Priority"
name="priority"
disabled={state.request === 'delete'}
value={state.values?.priority ?? 'medium'}
options={['low', 'medium', 'high', 'critical']}
onchange={(v) => state.setState('values', { ...state.values, priority: v })}
/>
<NativeSelectCtrl
label="Confidence"
name="confidence"
disabled={state.request === 'delete'}
value={state.values?.confidence ?? 'medium'}
options={['low', 'medium', 'high']}
onchange={(v) => state.setState('values', { ...state.values, confidence: v })}
/>
<SwitchCtrl
label="Action Required"
name="action_required"
disabled={state.request === 'delete'}
checked={state.values?.action_required ?? false}
onchange={(v) => state.setState('values', { ...state.values, action_required: v })}
/>
<TextInputCtrl
label="Source Type"
name="source_type"
disabled={state.request === 'delete'}
value={state.values?.source_type ?? ''}
onchange={(v) => state.setState('values', { ...state.values, source_type: v || undefined })}
/>
<TextInputCtrl
label="Source Ref"
name="source_ref"
disabled={state.request === 'delete'}
value={state.values?.source_ref ?? ''}
onchange={(v) => state.setState('values', { ...state.values, source_ref: v || undefined })}
/>
<TextInputCtrl
label="Tags"
name="tags"
placeholder="comma-separated"
disabled={state.request === 'delete'}
value={state.values?.tags ?? ''}
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
/>
</div>
{/snippet}
</FormerShell>
</ErrorBoundary>

View File

@@ -0,0 +1,456 @@
<script lang="ts">
import {
ErrorBoundary,
FormerResolveSpecAPI,
GridlerFull,
NativeSelectCtrl,
TextInputCtrl,
type GridlerColumn,
type GridlerContextMenuItem,
} from "@warkypublic/svelix";
import FormerShell from "../shared/FormerShell.svelte";
import { adminGridTheme } from "../../gridTheme";
import { GlobalStateStore } from "../../shellState";
import type { Plan } from "../../types";
import ContentEditorField from "../shared/ContentEditorField.svelte";
type PlanForm = {
id?: string;
title: string;
description: string;
status: string;
priority: string;
owner?: string;
due_date?: string;
tags?: string;
};
const PLAN_PRIMARY_KEY = 'id';
let selectedPlan = $state<Plan | null>(null);
let gridTotal = $state<number | null>(null);
let formOpened = $state(false);
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
let formValues = $state<PlanForm>({ title: '', description: '', status: 'draft', priority: 'medium' });
let editorOpened = $state(false);
let editorValues = $state<{ id?: string; description: string }>({ description: '' });
let contextRow = $state<Record<string, unknown> | null>(null);
let refreshKey = $state(0);
const authToken = GlobalStateStore.getState().session.authToken ?? '';
const planOnAPICall = $derived(FormerResolveSpecAPI({
authToken,
url: '/api/rs/public/plans'
}));
const menuItems: GridlerContextMenuItem[] = [
{ id: 'add', label: 'Add' },
{ id: 'edit', label: 'Edit' },
{ id: 'edit_content', label: 'Edit Content' },
{ id: 'delete', label: 'Delete' },
];
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
contextRow = rowData ?? null;
}
async function loadPlanFromRow(rowData: Record<string, unknown>): Promise<Plan> {
const data = await planOnAPICall('read', 'update', undefined, String(rowData[PLAN_PRIMARY_KEY] ?? '')) as Record<string, unknown>;
return normalizePlan(data);
}
function toPlanForm(plan: Plan): PlanForm {
return {
id: plan.id,
title: plan.title,
description: plan.description,
status: plan.status,
priority: plan.priority,
owner: plan.owner,
due_date: plan.due_date ? String(plan.due_date).slice(0, 10) : undefined,
tags: plan.tags.join(', ')
};
}
function normalizePlanRecordForFormer(data: Record<string, unknown>): PlanForm {
return {
id: data.id != null ? String(data.id) : undefined,
title: typeof data.title === 'string' ? data.title : '',
description: typeof data.description === 'string' ? data.description : '',
status: typeof data.status === 'string' ? data.status : 'draft',
priority: typeof data.priority === 'string' ? data.priority : 'medium',
owner: typeof data.owner === 'string' && data.owner ? data.owner : undefined,
due_date: typeof data.due_date === 'string' && data.due_date ? data.due_date.slice(0, 10) : undefined,
tags: normalizeTags(data.tags).join(', ')
};
}
async function onMenuItemSelect(item: GridlerContextMenuItem) {
if (item.id === 'add') {
formValues = { title: '', description: '', status: 'draft', priority: 'medium' };
formRequest = 'insert';
formOpened = true;
return;
}
if (!contextRow) return;
if (item.id === 'edit_content') {
const plan = normalizePlan(contextRow);
selectedPlan = plan;
editorValues = { id: plan.id, description: plan.description };
editorOpened = true;
return;
}
const plan = normalizePlan(contextRow);
formValues = toPlanForm(plan);
formRequest = item.id === 'delete' ? 'delete' : 'update';
formOpened = true;
}
function normalizePlanForm(data: PlanForm): Record<string, unknown> {
return {
title: data.title.trim(),
description: data.description,
status: data.status,
priority: data.priority,
owner: data.owner?.trim() || undefined,
due_date: data.due_date || undefined,
tags: data.tags?.split(',').map((t) => t.trim()).filter(Boolean) ?? []
};
}
async function handlePlanSaved() {
formOpened = false;
if (contextRow?.id) {
const data = await planOnAPICall('read', 'update', undefined, String(contextRow.id)) as Record<string, unknown>;
selectedPlan = normalizePlan(data);
}
refreshKey++;
}
async function handlePlanEditorSaved() {
editorOpened = false;
if (editorValues.id) {
const data = await planOnAPICall('read', 'update', undefined, String(editorValues.id)) as Record<string, unknown>;
selectedPlan = normalizePlan(data);
editorValues = { id: selectedPlan.id, description: selectedPlan.description };
}
refreshKey++;
}
const plansDataSourceOptions = {
url: "/api/rs",
authToken: GlobalStateStore.getState().session.authToken,
schema: "public",
entity: "plans",
uniqueID: PLAN_PRIMARY_KEY,
hotfields: [PLAN_PRIMARY_KEY],
sort: [{ column: "updated_at", direction: "desc" }],
} as unknown as {
url: string;
authToken?: string;
schema: string;
entity: string;
uniqueID: string;
hotfields: string[];
};
const columns: GridlerColumn[] = [
{ id: "title", title: "Title", dataKey: "title", width: 340 },
{ id: "status", title: "Status", dataKey: "status", width: 120 },
{ id: "priority", title: "Priority", dataKey: "priority", width: 110 },
{ id: "owner", title: "Owner", dataKey: "owner", width: 160 },
{ id: "due_date", title: "Due", dataKey: "due_date", width: 180, format: "datetime" },
{ id: "last_reviewed_at", title: "Reviewed", dataKey: "last_reviewed_at", width: 180, format: "datetime" },
{ id: "updated_at", title: "Updated", dataKey: "updated_at", width: 180, format: "datetime" },
];
function normalizeTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean);
if (typeof value !== "string" || !value.trim()) return [];
const trimmed = value.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
return trimmed.slice(1, -1).split(",").map((t) => t.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean);
}
return trimmed.split(",").map((t) => t.trim()).filter(Boolean);
}
function normalizePlan(rowData: Record<string, unknown>): Plan {
return {
id: String(rowData.id ?? ""),
title: typeof rowData.title === "string" ? rowData.title : "",
description: typeof rowData.description === "string" ? rowData.description : "",
status: (typeof rowData.status === "string" ? rowData.status : "draft") as Plan["status"],
priority: (typeof rowData.priority === "string" ? rowData.priority : "medium") as Plan["priority"],
project_id: typeof rowData.project_id === "string" ? rowData.project_id : undefined,
owner: typeof rowData.owner === "string" && rowData.owner ? rowData.owner : undefined,
due_date: typeof rowData.due_date === "string" ? rowData.due_date : undefined,
completed_at: typeof rowData.completed_at === "string" ? rowData.completed_at : undefined,
reviewed_by: typeof rowData.reviewed_by === "string" ? rowData.reviewed_by : undefined,
last_reviewed_at: typeof rowData.last_reviewed_at === "string" ? rowData.last_reviewed_at : undefined,
supersedes_plan_id: typeof rowData.supersedes_plan_id === "string" ? rowData.supersedes_plan_id : undefined,
tags: normalizeTags(rowData.tags),
created_at: String(rowData.created_at ?? ""),
updated_at: String(rowData.updated_at ?? ""),
};
}
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
selectedPlan = rowData ? normalizePlan(rowData) : null;
}
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
if (!rowData) return;
contextRow = rowData;
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
}
function onGridEvent(
type: string,
_item?: unknown,
_column?: unknown,
_coords?: unknown,
detail?: Record<string, unknown>,
) {
if (type !== "page_loaded" && type !== "load") return;
const total = detail?.total;
if (typeof total === "number") gridTotal = total;
}
function formatDate(value?: string): string {
if (!value) return "—";
return new Date(value).toLocaleString();
}
const statusClasses: Record<string, string> = {
draft: "bg-slate-700/60 text-slate-300",
active: "bg-cyan-900/60 text-cyan-200",
blocked: "bg-amber-900/60 text-amber-200",
completed: "bg-emerald-900/60 text-emerald-200",
cancelled: "bg-slate-800/60 text-slate-500",
superseded: "bg-purple-900/60 text-purple-300",
};
const priorityClasses: Record<string, string> = {
low: "bg-slate-700/60 text-slate-400",
medium: "bg-cyan-900/60 text-cyan-300",
high: "bg-amber-900/60 text-amber-300",
critical: "bg-rose-900/60 text-rose-300",
};
</script>
<div class="space-y-4 w-full">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Plans</h2>
<p class="mt-1 text-sm text-slate-400">
{#if gridTotal === null}
Server-backed grid
{:else}
{gridTotal} plan{gridTotal !== 1 ? "s" : ""}
{/if}
</p>
</div>
<div class="flex items-center gap-3">
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={() => { formValues = { title: '', description: '', status: 'draft', priority: 'medium' }; formRequest = 'insert'; formOpened = true; }}>New Plan</button
>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
{#key refreshKey}
<ErrorBoundary namespace="PlansGridlerFull">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={560}
width="100%"
pageSize={40}
dataSource="resolvespec"
dataSourceOptions={plansDataSourceOptions}
serverSideSearch={true}
searchColumns={["title", "description", "status", "priority", "owner"]}
{menuItems}
{onGridEvent}
{onRowClick}
{onRowDblClick}
{onRowContextMenu}
{onMenuItemSelect}
/>
</ErrorBoundary>
{/key}
</div>
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white">Plan Inspector</h3>
{#if selectedPlan}
<button
class="text-xs text-cyan-300 hover:text-cyan-200"
onclick={() => {
const plan = selectedPlan;
if (!plan) return;
editorValues = { id: plan.id, description: plan.description };
editorOpened = true;
}}>Edit Content</button>
{/if}
</div>
{#if !selectedPlan}
<p class="mt-3 text-sm text-slate-500">
Select a plan row to inspect details and relationships.
</p>
{:else}
<div class="mt-3 space-y-3 text-sm text-slate-300">
<p class="text-base font-semibold text-slate-100">{selectedPlan.title}</p>
<div class="flex flex-wrap gap-2">
<span class={`inline-flex items-center rounded-lg px-2.5 py-0.5 text-xs font-medium ${statusClasses[selectedPlan.status] ?? "bg-slate-700/60 text-slate-300"}`}>
{selectedPlan.status}
</span>
<span class={`inline-flex items-center rounded-lg px-2.5 py-0.5 text-xs font-medium ${priorityClasses[selectedPlan.priority] ?? "bg-slate-700/60 text-slate-300"}`}>
{selectedPlan.priority}
</span>
</div>
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
<p><strong class="text-slate-100">Owner:</strong> {selectedPlan.owner || "—"}</p>
<p><strong class="text-slate-100">Due:</strong> {formatDate(selectedPlan.due_date)}</p>
<p><strong class="text-slate-100">Completed:</strong> {formatDate(selectedPlan.completed_at)}</p>
<p><strong class="text-slate-100">Last reviewed:</strong> {formatDate(selectedPlan.last_reviewed_at)}</p>
<p><strong class="text-slate-100">Reviewed by:</strong> {selectedPlan.reviewed_by || "—"}</p>
<p><strong class="text-slate-100">Created:</strong> {formatDate(selectedPlan.created_at)}</p>
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedPlan.updated_at)}</p>
</div>
{#if selectedPlan.description}
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Description</p>
<p class="mt-2 whitespace-pre-wrap text-slate-300">{selectedPlan.description}</p>
</div>
{/if}
{#if selectedPlan.tags.length > 0}
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Tags</p>
<div class="mt-2 flex flex-wrap gap-1.5">
{#each selectedPlan.tags as tag}
<span class="rounded-md bg-white/10 px-2 py-0.5 text-xs text-slate-300">{tag}</span>
{/each}
</div>
</div>
{/if}
{#if selectedPlan.project_id || selectedPlan.supersedes_plan_id}
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
{#if selectedPlan.project_id}
<p><strong class="text-slate-100">Project:</strong> <span class="font-mono text-xs text-slate-400">{selectedPlan.project_id}</span></p>
{/if}
{#if selectedPlan.supersedes_plan_id}
<p><strong class="text-slate-100">Supersedes:</strong> <span class="font-mono text-xs text-slate-400">{selectedPlan.supersedes_plan_id}</span></p>
{/if}
</div>
{/if}
</div>
{/if}
</aside>
</div>
</div>
<ErrorBoundary namespace="PlansEditorFormer">
<FormerShell
bind:opened={editorOpened}
bind:values={editorValues}
request="update"
title="Edit Plan Description"
uniqueKeyField={PLAN_PRIMARY_KEY}
width="min(96vw, 90rem)"
onAPICall={planOnAPICall}
beforeSave={(data) => ({ description: data.description })}
afterSave={handlePlanEditorSaved}
onClose={() => { editorOpened = false; }}
>
{#snippet children(state)}
<ContentEditorField
filename="plan.md"
value={state.values?.description ?? ''}
onchange={(v) => state.setState('values', { ...state.values, description: v })}
/>
{/snippet}
</FormerShell>
</ErrorBoundary>
<ErrorBoundary namespace="PlansFormer">
<FormerShell
bind:opened={formOpened}
bind:values={formValues}
bind:request={formRequest}
title={formRequest === 'insert' ? 'New Plan' : formRequest === 'update' ? 'Edit Plan' : 'Delete Plan'}
uniqueKeyField={PLAN_PRIMARY_KEY}
onAPICall={planOnAPICall}
afterGet={async (data) => normalizePlanRecordForFormer(data as Record<string, unknown>)}
beforeSave={normalizePlanForm}
afterSave={handlePlanSaved}
onClose={() => { formOpened = false; }}
>
{#snippet children(state)}
<div class="space-y-4 p-4">
<TextInputCtrl
label="Title"
name="title"
required
disabled={state.request === 'delete'}
value={state.values?.title ?? ''}
onchange={(v) => state.setState('values', { ...state.values, title: v })}
/>
<ContentEditorField
filename="plan.md"
value={state.values?.description ?? ''}
disabled={state.request === 'delete'}
onchange={(v) => state.setState('values', { ...state.values, description: v })}
/>
<NativeSelectCtrl
label="Status"
name="status"
disabled={state.request === 'delete'}
value={state.values?.status ?? 'draft'}
options={['draft', 'active', 'blocked', 'completed', 'cancelled', 'superseded']}
onchange={(v) => state.setState('values', { ...state.values, status: v })}
/>
<NativeSelectCtrl
label="Priority"
name="priority"
disabled={state.request === 'delete'}
value={state.values?.priority ?? 'medium'}
options={['low', 'medium', 'high', 'critical']}
onchange={(v) => state.setState('values', { ...state.values, priority: v })}
/>
<TextInputCtrl
label="Owner"
name="owner"
disabled={state.request === 'delete'}
value={state.values?.owner ?? ''}
onchange={(v) => state.setState('values', { ...state.values, owner: v || undefined })}
/>
<TextInputCtrl
label="Due Date"
name="due_date"
type="date"
disabled={state.request === 'delete'}
value={state.values?.due_date ?? ''}
onchange={(v) => state.setState('values', { ...state.values, due_date: v || undefined })}
/>
<TextInputCtrl
label="Tags"
name="tags"
placeholder="comma-separated"
disabled={state.request === 'delete'}
value={state.values?.tags ?? ''}
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
/>
</div>
{/snippet}
</FormerShell>
</ErrorBoundary>

View File

@@ -1,27 +1,56 @@
<script lang="ts">
import { onMount } from 'svelte';
import { GridlerFull, TextInputCtrl, type GridlerColumn } from '@warkypublic/svelix';
import { api } from '../../api';
import {
ErrorBoundary,
FormerResolveSpecAPI,
GridlerFull,
TextInputCtrl,
type GridlerColumn,
type GridlerContextMenuItem
} from '@warkypublic/svelix';
import { GlobalStateStore } from '../../shellState';
import { adminGridTheme } from '../../gridTheme';
import type { ProjectSummary } from '../../types';
import FormerShell from '../shared/FormerShell.svelte';
import ContentEditorField from '../shared/ContentEditorField.svelte';
type ProjectForm = {
id?: string;
guid?: string;
name: string;
description: string;
};
const PROJECT_PRIMARY_KEY = 'id';
let projects = $state<ProjectSummary[]>([]);
let loading = $state(true);
let error = $state('');
let creating = $state(false);
let showCreate = $state(false);
let newName = $state('');
let newDesc = $state('');
let createError = $state('');
let selectedProject = $state<ProjectSummary | null>(null);
let selectedProjectRecordID = $state<string | null>(null);
let gridTotal = $state<number | null>(null);
let formOpened = $state(false);
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
let formValues = $state<ProjectForm>({ name: '', description: '' });
let editorOpened = $state(false);
let editorValues = $state<{ id?: string; description: string }>({ description: '' });
let contextRow = $state<Record<string, unknown> | null>(null);
let refreshKey = $state(0);
const authToken = GlobalStateStore.getState().session.authToken ?? '';
const projectOnAPICall = $derived(FormerResolveSpecAPI({
authToken,
url: '/api/rs/public/projects'
}));
const projectDataSourceOptions = {
url: '/api/rs',
authToken: GlobalStateStore.getState().session.authToken,
schema: 'public',
entity: 'projects',
uniqueID: 'id',
hotfields: ['id', 'guid'],
uniqueID: PROJECT_PRIMARY_KEY,
hotfields: [PROJECT_PRIMARY_KEY, 'guid'],
computedColumns: [
{
name: 'thought_count',
expression: 'COALESCE((SELECT COUNT(*) FROM public.thoughts t WHERE t.project_id = projects.guid), 0)'
}
],
sort: [{ column: 'created_at', direction: 'desc' }]
} as unknown as {
url: string;
@@ -30,6 +59,7 @@
entity: string;
uniqueID: string;
hotfields: string[];
computedColumns: { name: string; expression: string }[];
};
const columns: GridlerColumn[] = [
@@ -39,44 +69,30 @@
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 200, format: 'datetime' }
];
async function load() {
loading = true;
error = '';
try {
projects = await api.projects.list();
if (selectedProject) {
selectedProject = projects.find((project) => project.id === selectedProject?.id) ?? null;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load projects';
} finally {
loading = false;
}
}
const menuItems: GridlerContextMenuItem[] = [
{ id: 'add', label: 'Add' },
{ id: 'edit', label: 'Edit' },
{ id: 'edit_content', label: 'Edit Content' },
{ id: 'delete', label: 'Delete' }
];
async function create() {
if (!newName.trim()) return;
creating = true;
createError = '';
try {
await api.projects.create(newName.trim(), newDesc.trim());
newName = '';
newDesc = '';
showCreate = false;
await load();
} catch (e) {
createError = e instanceof Error ? e.message : 'Failed to create project';
} finally {
creating = false;
}
function toProjectForm(rowData: Record<string, unknown>): ProjectForm {
return {
id: String(rowData.id ?? ''),
guid: String(rowData.guid ?? ''),
name: String(rowData.name ?? ''),
description: String(rowData.description ?? '')
};
}
function onProjectRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
if (!rowData) {
selectedProject = null;
selectedProjectRecordID = null;
return;
}
const id = String(rowData.guid ?? rowData.id ?? '');
selectedProjectRecordID = String(rowData[PROJECT_PRIMARY_KEY] ?? '');
const id = String(rowData.guid ?? rowData[PROJECT_PRIMARY_KEY] ?? '');
selectedProject = {
id,
name: String(rowData.name ?? ''),
@@ -87,88 +103,186 @@
};
}
onMount(load);
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
contextRow = rowData ?? null;
}
function onMenuItemSelect(item: GridlerContextMenuItem) {
if (item.id === 'add') {
formValues = { name: '', description: '' };
formRequest = 'insert';
formOpened = true;
return;
}
if (!contextRow) return;
if (item.id === 'edit_content') {
onProjectRowClick(0, contextRow);
editorValues = {
id: String(contextRow[PROJECT_PRIMARY_KEY] ?? ''),
description: String(contextRow.description ?? '')
};
editorOpened = true;
return;
}
formValues = toProjectForm(contextRow);
formRequest = item.id === 'delete' ? 'delete' : 'update';
formOpened = true;
}
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
if (!rowData) return;
contextRow = rowData;
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
}
function normalizeProjectForm(data: ProjectForm): Record<string, unknown> {
return {
name: data.name.trim(),
description: data.description.trim()
};
}
async function handleProjectSaved() {
formOpened = false;
refreshKey += 1;
}
async function handleEditorSaved() {
editorOpened = false;
if (selectedProjectRecordID && editorValues.id === selectedProjectRecordID && selectedProject) {
selectedProject.description = editorValues.description;
}
refreshKey += 1;
}
</script>
<div class="space-y-6">
<div class="flex items-end justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Projects</h2>
<p class="mt-1 text-sm text-slate-400">{projects.length} project{projects.length !== 1 ? 's' : ''}</p>
<p class="mt-1 text-sm text-slate-400">
{#if gridTotal === null}
Server-backed grid
{:else}
{gridTotal} project{gridTotal !== 1 ? 's' : ''}
{/if}
</p>
</div>
<div class="flex gap-2">
<button
class="inline-flex items-center rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-200 transition hover:bg-white/10"
onclick={load}
>Refresh</button>
<button
class="inline-flex items-center rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20"
onclick={() => { showCreate = !showCreate; }}
onclick={() => {
formValues = { name: '', description: '' };
formRequest = 'insert';
formOpened = true;
}}
>New project</button>
</div>
</div>
{#if showCreate}
<div class="rounded-2xl border border-cyan-400/20 bg-slate-900 p-5">
<h3 class="text-sm font-semibold text-white">Create project</h3>
<div class="mt-3 space-y-3">
<TextInputCtrl
label="Project name"
placeholder="Name"
required
bind:value={newName}
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
{#key refreshKey}
<ErrorBoundary namespace="ProjectsGridlerFull">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={420}
dataSource="resolvespec"
dataSourceOptions={projectDataSourceOptions}
serverSideSearch={true}
searchColumns={['name', 'description']}
{menuItems}
onRowClick={onProjectRowClick}
onGridEvent={(type, _item, _column, _coords, detail) => {
if (type !== 'page_loaded' && type !== 'load') return;
const total = detail?.total;
if (typeof total === 'number') gridTotal = total;
}}
{onRowDblClick}
{onRowContextMenu}
{onMenuItemSelect}
/>
<TextInputCtrl
label="Description"
placeholder="Description (optional)"
bind:value={newDesc}
/>
{#if createError}<p class="text-xs text-rose-300">{createError}</p>{/if}
<div class="flex gap-2">
<button
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20 disabled:opacity-50"
onclick={create}
disabled={creating || !newName.trim()}
>{creating ? 'Creating…' : 'Create'}</button>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300 transition hover:bg-white/10"
onclick={() => { showCreate = false; createError = ''; }}
>Cancel</button>
</div>
</div>
</div>
{/if}
{#if loading}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">
Loading…
</div>
{:else if error}
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
{:else if projects.length === 0}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">
No projects yet.
</div>
{:else}
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={420}
dataSource="resolvespec"
dataSourceOptions={projectDataSourceOptions}
serverSideSearch={true}
searchColumns={['name', 'description']}
onRowClick={onProjectRowClick}
/>
</div>
{#if selectedProject}
</ErrorBoundary>
{/key}
</div>
{#if selectedProject}
<div class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
<h3 class="text-sm font-semibold text-white">Selected project</h3>
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-white">Selected project</h3>
<button
class="text-xs text-cyan-300 hover:text-cyan-200"
onclick={() => {
const project = selectedProject;
if (!project) return;
editorValues = {
id: selectedProjectRecordID ?? undefined,
description: project.description
};
editorOpened = true;
}}
>Edit Content</button>
</div>
<p class="mt-2 text-sm text-slate-300"><strong class="text-slate-100">{selectedProject.name}</strong> · {selectedProject.thought_count} thoughts</p>
<p class="mt-1 text-sm text-slate-400">{selectedProject.description || 'No description.'}</p>
</div>
{/if}
{/if}
</div>
<ErrorBoundary namespace="ProjectsEditorFormer">
<FormerShell
bind:opened={editorOpened}
bind:values={editorValues}
request="update"
title="Edit Project Description"
uniqueKeyField={PROJECT_PRIMARY_KEY}
width="min(96vw, 90rem)"
onAPICall={projectOnAPICall}
beforeSave={(data) => ({ description: data.description.trim() })}
afterSave={handleEditorSaved}
onClose={() => {
editorOpened = false;
}}
>
{#snippet children(state)}
<ContentEditorField
filename="project.md"
value={state.values?.description ?? ''}
onchange={(v) => state.setState('values', { ...state.values, description: v })}
/>
{/snippet}
</FormerShell>
</ErrorBoundary>
<ErrorBoundary namespace="ProjectsFormer">
<FormerShell
bind:opened={formOpened}
bind:values={formValues}
bind:request={formRequest}
title={formRequest === 'insert' ? 'New Project' : formRequest === 'update' ? 'Edit Project' : 'Delete Project'}
uniqueKeyField={PROJECT_PRIMARY_KEY}
onAPICall={projectOnAPICall}
beforeSave={normalizeProjectForm}
afterSave={handleProjectSaved}
onClose={() => {
formOpened = false;
}}
>
{#snippet children(state)}
<TextInputCtrl
label="Project name"
name="name"
required
disabled={state.request === 'delete'}
value={state.values?.name ?? ''}
onchange={(v) => state.setState('values', { ...state.values, name: v })}
/>
<ContentEditorField
filename="project.md"
value={state.values?.description ?? ''}
disabled={state.request === 'delete'}
onchange={(v) => state.setState('values', { ...state.values, description: v })}
/>
{/snippet}
</FormerShell>
</ErrorBoundary>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { ContentEditor } from '@warkypublic/svelix';
interface Props {
value: string;
filename: string;
disabled?: boolean;
onchange: (text: string) => void;
}
let { value, filename, disabled = false, onchange }: Props = $props();
let currentBlob = $state<Blob | undefined>(undefined);
const initialBlob = $derived(new Blob([value ?? ''], { type: 'text/plain' }));
$effect(() => {
currentBlob = undefined;
});
async function handleBlobChange(blob?: Blob) {
currentBlob = blob;
if (!blob) {
onchange('');
return;
}
onchange(await blob.text());
}
</script>
<div class="rounded-xl border border-white/10 bg-slate-950/60 overflow-hidden">
<div class="border-b border-white/10 px-4 py-2 text-xs uppercase tracking-[0.18em] text-slate-500">
{filename}
</div>
<div class:opacity-60={disabled} class:pointer-events-none={disabled}>
<ContentEditor
value={currentBlob ?? initialBlob}
{filename}
colorScheme="dark"
hideHeader={true}
onChange={(blob) => { void handleBlobChange(blob); }}
/>
</div>
</div>

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import { ContentEditor } from '@warkypublic/svelix';
interface Props {
open: boolean;
title: string;
content: string;
filename: string;
onSave: (text: string) => Promise<void>;
onClose: () => void;
}
let { open, title, content, filename, onSave, onClose }: Props = $props();
let saveError = $state('');
let saving = $state(false);
let fullscreen = $state(false);
let currentBlob = $state<Blob | undefined>(undefined);
const initialBlob = $derived(open ? new Blob([content], { type: 'text/plain' }) : undefined);
$effect(() => {
if (open) currentBlob = undefined;
});
async function save() {
const blob = currentBlob ?? initialBlob;
if (!blob) return;
saveError = '';
saving = true;
try {
const text = await blob.text();
await onSave(text);
onClose();
} catch (e) {
saveError = e instanceof Error ? e.message : 'Save failed';
} finally {
saving = false;
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { if (fullscreen) { fullscreen = false; } else { onClose(); } }
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
void save();
}
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-8" class:p-0={fullscreen}>
<div
class="flex flex-col w-full max-w-5xl bg-slate-900 transition-all"
class:max-w-5xl={!fullscreen}
style={fullscreen ? 'height: 100vh; border-radius: 0; border: none;' : 'height: 85vh; border-radius: 1rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.1);'}
>
<div class="flex-none flex items-center justify-between px-5 py-3 border-b border-white/10 bg-slate-800">
<h2 class="text-sm font-semibold text-white">{title}</h2>
<div class="flex items-center gap-4">
{#if saveError}
<p class="text-xs text-rose-300">{saveError}</p>
{/if}
<button
onclick={() => { fullscreen = !fullscreen; }}
class="text-xs text-slate-400 hover:text-white"
title={fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>{fullscreen ? '⊡' : '⛶'}</button>
<button
onclick={onClose}
class="text-xs text-slate-400 hover:text-white"
>Cancel</button>
<button
onclick={save}
disabled={saving}
class="rounded-lg bg-cyan-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-cyan-500 disabled:opacity-50"
>{saving ? 'Saving…' : 'Save'}</button>
</div>
</div>
<div class="flex-1 overflow-hidden bg-slate-900">
<ContentEditor
value={initialBlob}
{filename}
colorScheme="dark"
hideHeader={true}
onChange={(v) => { currentBlob = v; }}
onSave={save}
/>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { FormerDrawer } from '@warkypublic/svelix';
import type { FormerProps, FormRequestType } from '@warkypublic/svelix';
import type { Snippet } from 'svelte';
interface Props extends FormerProps<any> {
title?: string;
width?: string;
children?: Snippet<[any]>;
}
let {
title = 'Form',
opened = $bindable(false),
values = $bindable<any>(undefined),
request = $bindable<FormRequestType>('insert'),
layout = { buttonArea: 'bottom' },
width = '36rem',
children: formContent,
...rest
}: Props = $props();
</script>
<FormerDrawer
bind:opened
bind:values
bind:request
{title}
{layout}
{width}
{...rest}
>
{#snippet children(state)}
<div class="space-y-4 p-6">
{@render formContent?.(state)}
</div>
{/snippet}
</FormerDrawer>

View File

@@ -3,6 +3,7 @@
import FilesPage from '../files/FilesPage.svelte';
import GuardrailsPage from '../guardrails/GuardrailsPage.svelte';
import LearningsPage from '../learnings/LearningsPage.svelte';
import PlansPage from '../plans/PlansPage.svelte';
import MaintenancePage from '../maintenance/MaintenancePage.svelte';
import DashboardPage from '../dashboard/DashboardPage.svelte';
import ProjectsPage from '../projects/ProjectsPage.svelte';
@@ -41,6 +42,8 @@
<ThoughtsPage />
{:else if currentPage === 'learnings'}
<LearningsPage />
{:else if currentPage === 'plans'}
<PlansPage />
{:else if currentPage === 'skills'}
<SkillsPage />
{:else if currentPage === 'guardrails'}

View File

@@ -16,6 +16,7 @@
{ id: 'projects', label: 'Projects', description: 'Browse and manage projects.' },
{ id: 'thoughts', label: 'Thoughts', description: 'Search and inspect thoughts.' },
{ id: 'learnings', label: 'Learnings', description: 'Curated insights and outcomes.' },
{ id: 'plans', label: 'Plans', description: 'Structured plans and workstreams.' },
{ id: 'skills', label: 'Skills', description: 'Agent skill registry.' },
{ id: 'guardrails', label: 'Guardrails', description: 'Agent guardrail registry.' },
{ id: 'files', label: 'Files', description: 'Stored file inventory.' },

View File

@@ -1,91 +1,382 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '../../api';
import {
ErrorBoundary,
FormerResolveSpecAPI,
GridlerFull,
TextInputCtrl,
type GridlerColumn,
type GridlerContextMenuItem
} from '@warkypublic/svelix';
import { adminGridTheme } from '../../gridTheme';
import { GlobalStateStore } from '../../shellState';
import type { AgentSkill } from '../../types';
import FormerShell from '../shared/FormerShell.svelte';
import ContentEditorField from '../shared/ContentEditorField.svelte';
let skills = $state<AgentSkill[]>([]);
let loading = $state(true);
let error = $state('');
let busy = $state<string | null>(null);
type SkillForm = {
id?: string;
name: string;
description: string;
content: string;
tags: string;
};
async function load() {
loading = true;
error = '';
try {
skills = await api.skills.list();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load skills';
} finally {
loading = false;
const SKILL_PRIMARY_KEY = 'id';
let selectedSkill = $state<AgentSkill | null>(null);
let gridTotal = $state<number | null>(null);
let formOpened = $state(false);
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
let formValues = $state<SkillForm>({
name: '',
description: '',
content: '',
tags: ''
});
let editorOpened = $state(false);
let editorValues = $state<{ id?: string; content: string }>({ content: '' });
let contextRow = $state<Record<string, unknown> | null>(null);
let refreshKey = $state(0);
const authToken = GlobalStateStore.getState().session.authToken ?? '';
const skillOnAPICall = $derived(FormerResolveSpecAPI({
authToken,
url: '/api/rs/public/agent_skills'
}));
const skillsDataSourceOptions = {
url: '/api/rs',
authToken: GlobalStateStore.getState().session.authToken,
schema: 'public',
entity: 'agent_skills',
uniqueID: SKILL_PRIMARY_KEY,
hotfields: [SKILL_PRIMARY_KEY],
sort: [{ column: 'created_at', direction: 'desc' }]
} as unknown as {
url: string;
authToken?: string;
schema: string;
entity: string;
uniqueID: string;
hotfields: string[];
};
const columns: GridlerColumn[] = [
{ id: 'name', title: 'Name', dataKey: 'name', width: 240 },
{ id: 'description', title: 'Description', dataKey: 'description', width: 300 },
{ id: 'tags', title: 'Tags', dataKey: 'tags', width: 220 },
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 180, format: 'datetime' },
{ id: 'updated_at', title: 'Updated', dataKey: 'updated_at', width: 180, format: 'datetime' }
];
const menuItems: GridlerContextMenuItem[] = [
{ id: 'add', label: 'Add' },
{ id: 'edit', label: 'Edit' },
{ id: 'edit_content', label: 'Edit Content' },
{ id: 'delete', label: 'Delete' }
];
function normalizeTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
if (typeof value !== 'string' || !value.trim()) return [];
const trimmed = value.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
return trimmed
.slice(1, -1)
.split(',')
.map((tag) => tag.trim().replace(/^"(.*)"$/, '$1'))
.filter(Boolean);
}
return trimmed.split(',').map((tag) => tag.trim()).filter(Boolean);
}
async function remove(id: string, name: string) {
if (!confirm(`Delete skill "${name}"?`)) return;
busy = id;
try {
await api.skills.delete(id);
await load();
} catch (e) {
error = e instanceof Error ? e.message : 'Delete failed';
} finally {
busy = null;
}
function normalizeSkill(rowData: Record<string, unknown>): AgentSkill {
return {
id: String(rowData.id ?? ''),
name: String(rowData.name ?? ''),
description: String(rowData.description ?? ''),
content: String(rowData.content ?? ''),
tags: normalizeTags(rowData.tags),
created_at: String(rowData.created_at ?? ''),
updated_at: String(rowData.updated_at ?? '')
};
}
onMount(load);
function toSkillForm(skill: AgentSkill): SkillForm {
return {
id: skill.id,
name: skill.name,
description: skill.description,
content: skill.content,
tags: skill.tags.join(', ')
};
}
function normalizeSkillRecordForFormer(data: Record<string, unknown>): SkillForm {
return {
id: data.id != null ? String(data.id) : undefined,
name: typeof data.name === 'string' ? data.name : '',
description: typeof data.description === 'string' ? data.description : '',
content: typeof data.content === 'string' ? data.content : '',
tags: normalizeTags(data.tags).join(', ')
};
}
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
selectedSkill = rowData ? normalizeSkill(rowData) : null;
}
async function loadSkillFromRow(rowData: Record<string, unknown>): Promise<AgentSkill> {
const id = String(rowData[SKILL_PRIMARY_KEY] ?? '');
const data = await skillOnAPICall('read', 'update', undefined, id) as Record<string, unknown>;
return normalizeSkill(data);
}
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
contextRow = rowData ?? null;
}
async function onMenuItemSelect(item: GridlerContextMenuItem) {
if (item.id === 'add') {
formValues = { name: '', description: '', content: '', tags: '' };
formRequest = 'insert';
formOpened = true;
return;
}
if (!contextRow) return;
if (item.id === 'edit_content') {
const skill = normalizeSkill(contextRow);
selectedSkill = skill;
editorValues = { id: skill.id, content: skill.content };
editorOpened = true;
return;
}
const skill = normalizeSkill(contextRow);
formValues = toSkillForm(skill);
formRequest = item.id === 'delete' ? 'delete' : 'update';
formOpened = true;
}
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
if (!rowData) return;
contextRow = rowData;
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
}
function onGridEvent(
type: string,
_item?: unknown,
_column?: unknown,
_coords?: unknown,
detail?: Record<string, unknown>
) {
if (type !== 'page_loaded' && type !== 'load') return;
const total = detail?.total;
if (typeof total === 'number') gridTotal = total;
}
function normalizeSkillForm(data: SkillForm): Record<string, unknown> {
return {
name: data.name.trim(),
description: data.description.trim(),
content: data.content,
tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean)
};
}
async function handleSkillSaved() {
formOpened = false;
if (contextRow?.[SKILL_PRIMARY_KEY]) {
const data = await skillOnAPICall('read', 'update', undefined, String(contextRow[SKILL_PRIMARY_KEY])) as Record<string, unknown>;
selectedSkill = normalizeSkill(data);
}
refreshKey += 1;
}
async function handleEditorSaved() {
editorOpened = false;
if (editorValues.id) {
const data = await skillOnAPICall('read', 'update', undefined, String(editorValues.id)) as Record<string, unknown>;
const refreshed = normalizeSkill(data);
selectedSkill = refreshed;
editorValues = { id: refreshed.id, content: refreshed.content };
}
refreshKey += 1;
}
function formatDate(value?: string): string {
if (!value) return '—';
return new Date(value).toLocaleString();
}
</script>
<div class="space-y-4">
<div class="flex items-end justify-between">
<div class="space-y-4 w-full">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Skills</h2>
<p class="mt-1 text-sm text-slate-400">{skills.length} skill{skills.length !== 1 ? 's' : ''}</p>
<p class="mt-1 text-sm text-slate-400">
{#if gridTotal === null}
Server-backed grid
{:else}
{gridTotal} skill{gridTotal !== 1 ? 's' : ''}
{/if}
</p>
</div>
<div class="flex items-center gap-3">
<button
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20"
onclick={() => {
formValues = { name: '', description: '', content: '', tags: '' };
formRequest = 'insert';
formOpened = true;
}}
>New Skill</button>
</div>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={load}
>Refresh</button>
</div>
{#if error}
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
{/if}
{#if loading}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
{:else if skills.length === 0}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No skills registered.</div>
{:else}
<div class="space-y-3">
{#each skills as skill}
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="font-semibold text-white">{skill.name}</p>
{#if skill.description}
<p class="mt-1 text-sm text-slate-400">{skill.description}</p>
{/if}
{#if skill.tags?.length}
<div class="mt-2 flex flex-wrap gap-1">
{#each skill.tags as tag}
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">{tag}</span>
{/each}
</div>
{/if}
</div>
<button
class="shrink-0 text-xs text-rose-400 hover:text-rose-300 disabled:opacity-40"
onclick={() => remove(skill.id, skill.name)}
disabled={busy === skill.id}
>Delete</button>
</div>
<details class="mt-3">
<summary class="cursor-pointer text-xs text-slate-500 hover:text-slate-300">View content</summary>
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{skill.content}</pre>
</details>
</div>
{/each}
<div class="flex flex-col gap-4">
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
{#key refreshKey}
<ErrorBoundary namespace="SkillsGridlerFull">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={420}
width="100%"
pageSize={40}
dataSource="resolvespec"
dataSourceOptions={skillsDataSourceOptions}
serverSideSearch={true}
searchColumns={['name', 'description', 'content', 'tags']}
{menuItems}
{onGridEvent}
{onRowClick}
{onRowDblClick}
{onRowContextMenu}
{onMenuItemSelect}
/>
</ErrorBoundary>
{/key}
</div>
{/if}
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
<div class="flex items-start justify-between gap-3">
<h3 class="text-sm font-semibold text-white">Skill Inspector</h3>
{#if selectedSkill}
<button
class="text-xs text-cyan-300 hover:text-cyan-200"
onclick={() => {
if (!selectedSkill) return;
editorValues = { id: selectedSkill.id, content: selectedSkill.content };
editorOpened = true;
}}
>Edit Content</button>
{/if}
</div>
{#if !selectedSkill}
<p class="mt-3 text-sm text-slate-500">
Select a skill row to inspect details.
</p>
{:else}
<div class="mt-3 space-y-3 text-sm text-slate-300">
<p class="text-base font-semibold text-slate-100">{selectedSkill.name}</p>
<p><strong class="text-slate-100">Description:</strong> {selectedSkill.description || '—'}</p>
<p><strong class="text-slate-100">Created:</strong> {formatDate(selectedSkill.created_at)}</p>
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedSkill.updated_at)}</p>
<div>
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Tags</p>
<div class="mt-2 flex flex-wrap gap-2">
{#if selectedSkill.tags.length}
{#each selectedSkill.tags as tag}
<span class="rounded-md bg-white/10 px-2 py-0.5 text-xs text-slate-300">{tag}</span>
{/each}
{:else}
<span class="text-slate-500">No tags</span>
{/if}
</div>
</div>
<div>
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Content</p>
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{selectedSkill.content}</pre>
</div>
</div>
{/if}
</aside>
</div>
</div>
<ErrorBoundary namespace="SkillsEditorFormer">
<FormerShell
bind:opened={editorOpened}
bind:values={editorValues}
request="update"
title="Edit Skill Content"
uniqueKeyField={SKILL_PRIMARY_KEY}
width="min(96vw, 90rem)"
onAPICall={skillOnAPICall}
beforeSave={(data) => ({ content: data.content })}
afterSave={handleEditorSaved}
onClose={() => {
editorOpened = false;
}}
>
{#snippet children(state)}
<ContentEditorField
filename="skill.md"
value={state.values?.content ?? ''}
disabled={state.request === 'delete'}
onchange={(v) => state.setState('values', { ...state.values, content: v })}
/>
{/snippet}
</FormerShell>
</ErrorBoundary>
<ErrorBoundary namespace="SkillsFormer">
<FormerShell
bind:opened={formOpened}
bind:values={formValues}
bind:request={formRequest}
title={formRequest === 'insert' ? 'New Skill' : formRequest === 'update' ? 'Edit Skill' : 'Delete Skill'}
uniqueKeyField={SKILL_PRIMARY_KEY}
onAPICall={skillOnAPICall}
afterGet={async (data) => normalizeSkillRecordForFormer(data as Record<string, unknown>)}
beforeSave={normalizeSkillForm}
afterSave={handleSkillSaved}
onClose={() => {
formOpened = false;
}}
>
{#snippet children(state)}
<TextInputCtrl
label="Name"
name="name"
required
disabled={state.request === 'delete'}
value={state.values?.name ?? ''}
onchange={(v) => state.setState('values', { ...state.values, name: v })}
/>
<TextInputCtrl
label="Description"
name="description"
disabled={state.request === 'delete'}
value={state.values?.description ?? ''}
onchange={(v) => state.setState('values', { ...state.values, description: v })}
/>
<TextInputCtrl
label="Tags"
name="tags"
placeholder="comma-separated"
disabled={state.request === 'delete'}
value={state.values?.tags ?? ''}
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
/>
<ContentEditorField
filename="skill.md"
value={state.values?.content ?? ''}
onchange={(v) => state.setState('values', { ...state.values, content: v })}
/>
{/snippet}
</FormerShell>
</ErrorBoundary>

View File

@@ -1,13 +1,24 @@
<script lang="ts">
import {
ErrorBoundary,
FormerResolveSpecAPI,
GridlerFull,
NativeSelectCtrl,
type GridColumnFilters,
type GridlerColumn,
type GridlerContextMenuItem,
} from "@warkypublic/svelix";
import FormerShell from "../shared/FormerShell.svelte";
import { onMount } from "svelte";
import { api } from "../../api";
import { GlobalStateStore } from "../../shellState";
import { adminGridTheme } from "../../gridTheme";
import { GlobalStateStore } from "../../shellState";
import type { StoredFile, Thought, ThoughtLink } from "../../types";
import ContentEditorField from "../shared/ContentEditorField.svelte";
type ThoughtForm = { id?: string; content: string; project_id?: string };
const THOUGHT_PRIMARY_KEY = 'id';
let includeArchived = $state(false);
let actionBusy = $state<string | null>(null);
@@ -16,14 +27,107 @@
let selectedThought = $state<Thought | null>(null);
let relatedLinks = $state<ThoughtLink[]>([]);
let relatedFiles = $state<StoredFile[]>([]);
let formOpened = $state(false);
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
let formValues = $state<ThoughtForm>({ content: '' });
let editorOpened = $state(false);
let editorValues = $state<{ id?: string; content: string }>({ content: '' });
let contextRow = $state<Record<string, unknown> | null>(null);
let refreshKey = $state(0);
let projectOptions = $state<{ label: string; value: string }[]>([]);
const authToken = GlobalStateStore.getState().session.authToken ?? '';
const thoughtOnAPICall = $derived(FormerResolveSpecAPI({
authToken,
url: '/api/rs/public/thoughts'
}));
onMount(async () => {
const projects = await api.projects.list();
projectOptions = projects.map((p) => ({ label: p.name, value: p.id }));
});
const menuItems: GridlerContextMenuItem[] = [
{ id: 'add', label: 'Add' },
{ id: 'edit', label: 'Edit' },
{ id: 'edit_content', label: 'Edit Content' },
{ id: 'delete', label: 'Delete' },
];
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
contextRow = rowData ?? null;
}
function normalizeThoughtRecordForFormer(data: Record<string, unknown>): ThoughtForm {
return {
id: data.id != null ? String(data.id) : undefined,
content: typeof data.content === 'string' ? data.content : '',
project_id: typeof data.project_id === 'string' && data.project_id ? data.project_id : undefined,
};
}
async function loadThoughtFromRow(rowData: Record<string, unknown>): Promise<Thought> {
const id = String(rowData[THOUGHT_PRIMARY_KEY] ?? '');
return await thoughtOnAPICall('read', 'update', undefined, id) as Thought;
}
async function onMenuItemSelect(item: GridlerContextMenuItem) {
if (item.id === 'add') {
formValues = { content: '' };
formRequest = 'insert';
formOpened = true;
return;
}
if (!contextRow) return;
if (item.id === 'edit_content') {
const thought = normalizeThought(contextRow);
selectedThought = thought;
editorValues = { id: thought.id, content: thought.content };
editorOpened = true;
return;
}
const thought = normalizeThought(contextRow);
formValues = {
id: thought.id,
content: thought.content,
project_id: thought.project_id
};
formRequest = item.id === 'delete' ? 'delete' : 'update';
formOpened = true;
}
function normalizeThoughtForm(data: ThoughtForm): Record<string, unknown> {
return {
content: data.content,
project_id: data.project_id || undefined
};
}
async function handleThoughtSaved() {
formOpened = false;
if (contextRow?.id) {
const thought = await loadThoughtFromRow(contextRow);
await inspectThought(thought);
}
refreshKey++;
}
async function handleThoughtEditorSaved() {
editorOpened = false;
if (editorValues.id) {
const thought = await thoughtOnAPICall('read', 'update', undefined, editorValues.id) as Thought;
editorValues = { id: thought.id, content: thought.content };
await inspectThought(thought);
}
refreshKey++;
}
let gridTotal = $state<number | null>(null);
const thoughtsDataSourceOptions = {
url: "/api/rs",
authToken: GlobalStateStore.getState().session.authToken,
schema: "public",
entity: "thoughts",
uniqueID: "id",
hotfields: ["guid", "metadata", "project_id", "archived_at"],
uniqueID: THOUGHT_PRIMARY_KEY,
hotfields: [THOUGHT_PRIMARY_KEY, "guid", "metadata", "project_id", "archived_at"],
sort: [{ column: "created_at", direction: "desc" }],
} as unknown as {
url: string;
@@ -177,6 +281,12 @@
void inspectThought(normalizeThought(rowData));
}
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
if (!rowData) return;
contextRow = rowData;
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
}
function onGridEvent(
type: string,
_item?: unknown,
@@ -220,6 +330,10 @@
/>
Archived
</label>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={() => { formValues = { content: '' }; formRequest = 'insert'; formOpened = true; }}>New Thought</button
>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={() => {
@@ -235,23 +349,31 @@
<p class="text-sm text-rose-300">{actionError}</p>
{/if}
<div class="grid gap-4 xl:grid-cols-[1.6fr_1fr]">
<div class="flex flex-col gap-4">
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={560}
width="100%"
pageSize={40}
dataSource="resolvespec"
dataSourceOptions={thoughtsDataSourceOptions}
serverSideSearch={true}
searchColumns={["content"]}
filters={baseFilters}
{onGridEvent}
{onRowClick}
/>
{#key refreshKey}
<ErrorBoundary namespace="ThoughtsGridlerFull">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={560}
width="100%"
pageSize={40}
dataSource="resolvespec"
dataSourceOptions={thoughtsDataSourceOptions}
serverSideSearch={true}
searchColumns={["content"]}
filters={baseFilters}
{menuItems}
{onGridEvent}
{onRowClick}
{onRowDblClick}
{onRowContextMenu}
{onMenuItemSelect}
/>
</ErrorBoundary>
{/key}
</div>
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
@@ -259,6 +381,15 @@
<h3 class="text-sm font-semibold text-white">Thought Inspector</h3>
{#if selectedThought}
<div class="flex gap-2">
<button
class="text-xs text-cyan-300 hover:text-cyan-200"
onclick={() => {
const thought = selectedThought;
if (!thought) return;
editorValues = { id: thought.id, content: thought.content };
editorOpened = true;
}}>Edit Content</button
>
{#if !isSelectedArchived()}
<button
class="text-xs text-slate-300 hover:text-white"
@@ -387,3 +518,60 @@
</aside>
</div>
</div>
<ErrorBoundary namespace="ThoughtsEditorFormer">
<FormerShell
bind:opened={editorOpened}
bind:values={editorValues}
request="update"
title="Edit Thought"
uniqueKeyField={THOUGHT_PRIMARY_KEY}
width="min(96vw, 90rem)"
onAPICall={thoughtOnAPICall}
beforeSave={(data) => ({ content: data.content })}
afterSave={handleThoughtEditorSaved}
onClose={() => { editorOpened = false; }}
>
{#snippet children(state)}
<ContentEditorField
filename="thought.md"
value={state.values?.content ?? ''}
onchange={(v) => state.setState('values', { ...state.values, content: v })}
/>
{/snippet}
</FormerShell>
</ErrorBoundary>
<ErrorBoundary namespace="ThoughtsFormer">
<FormerShell
bind:opened={formOpened}
bind:values={formValues}
bind:request={formRequest}
title={formRequest === 'insert' ? 'New Thought' : formRequest === 'update' ? 'Edit Thought' : 'Delete Thought'}
uniqueKeyField={THOUGHT_PRIMARY_KEY}
onAPICall={thoughtOnAPICall}
afterGet={async (data) => normalizeThoughtRecordForFormer(data as Record<string, unknown>)}
beforeSave={normalizeThoughtForm}
afterSave={handleThoughtSaved}
onClose={() => { formOpened = false; }}
>
{#snippet children(state)}
<div class="space-y-4 p-4">
<ContentEditorField
filename="thought.md"
value={state.values?.content ?? ''}
disabled={state.request === 'delete'}
onchange={(v) => state.setState('values', { ...state.values, content: v })}
/>
<NativeSelectCtrl
label="Project"
name="project_id"
disabled={state.request === 'delete'}
value={state.values?.project_id ?? ''}
options={[{ label: '— None —', value: '' }, ...projectOptions]}
onchange={(v) => state.setState('values', { ...state.values, project_id: v || undefined })}
/>
</div>
{/snippet}
</FormerShell>
</ErrorBoundary>

View File

@@ -37,6 +37,18 @@ export type StatusResponse = {
metrics: AccessMetrics;
};
export type PublicStatusClient = {
key_id: string;
request_count: number;
last_accessed_at: string;
};
export type PublicStatusResponse = {
connected_count: number;
connected_window: string;
entries: PublicStatusClient[];
};
export type NavItem = {
id: string;
label: string;
@@ -44,7 +56,7 @@ export type NavItem = {
disabled?: boolean;
};
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'skills' | 'guardrails' | 'files' | 'maintenance';
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'plans' | 'skills' | 'guardrails' | 'files' | 'maintenance';
export type Project = {
id: string;
@@ -169,6 +181,24 @@ export type Learning = {
updated_at: string;
};
export type Plan = {
id: string;
title: string;
description: string;
status: 'draft' | 'active' | 'blocked' | 'completed' | 'cancelled' | 'superseded';
priority: 'low' | 'medium' | 'high' | 'critical';
project_id?: string;
owner?: string;
due_date?: string;
completed_at?: string;
reviewed_by?: string;
last_reviewed_at?: string;
supersedes_plan_id?: string;
tags: string[];
created_at: string;
updated_at: string;
};
export type MaintenanceTask = {
id: string;
name: string;

File diff suppressed because one or more lines are too long

View File

@@ -11,6 +11,7 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': backendTarget,
'/status': backendTarget,
'/healthz': backendTarget,
'/readyz': backendTarget,
'/llm': backendTarget,