feat(tools): add maintenance and meal planning tools with CRUD operations

- Implement maintenance tool for adding, logging, and retrieving tasks
- Create meals tool for managing recipes, meal plans, and shopping lists
- Introduce reparse metadata tool for updating thought metadata
- Add household knowledge, home maintenance, family calendar, meal planning, and professional CRM database migrations
- Grant necessary permissions for new database tables
This commit is contained in:
2026-03-26 23:29:03 +02:00
parent b74d63c543
commit 0eb6ac7ee5
25 changed files with 2910 additions and 10 deletions

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@ go.work.sum
configs/*.local.yaml configs/*.local.yaml
cmd/amcs-server/__debug_* cmd/amcs-server/__debug_*
bin/ bin/
OB1/

View File

@@ -42,6 +42,7 @@ A Go MCP server for capturing and retrieving thoughts, memory, and project conte
| `link_thoughts` | Create a typed relationship between thoughts | | `link_thoughts` | Create a typed relationship between thoughts |
| `related_thoughts` | Explicit links + semantic neighbours | | `related_thoughts` | Explicit links + semantic neighbours |
| `backfill_embeddings` | Generate missing embeddings for stored thoughts | | `backfill_embeddings` | Generate missing embeddings for stored thoughts |
| `reparse_thought_metadata` | Re-extract and normalize metadata for stored thoughts |
## Configuration ## Configuration
@@ -93,6 +94,24 @@ Run `backfill_embeddings` after switching embedding models or importing thoughts
- `limit` — max thoughts per call (default 100) - `limit` — max thoughts per call (default 100)
- Embeddings are generated in parallel (4 workers) and upserted; one failure does not abort the run - Embeddings are generated in parallel (4 workers) and upserted; one failure does not abort the run
## Metadata Reparse
Run `reparse_thought_metadata` to fix stale or inconsistent metadata by re-extracting it from thought content.
```json
{
"project": "optional-project-name",
"limit": 100,
"include_archived": false,
"older_than_days": 0,
"dry_run": false
}
```
- `dry_run: true` scans only and does not call metadata extraction or write updates
- If extraction fails for a thought, existing metadata is normalized and written only if it changes
- Metadata reparse runs in parallel (4 workers); one failure does not abort the run
**Automatic backfill** (optional, config-gated): **Automatic backfill** (optional, config-gated):
```yaml ```yaml

View File

@@ -142,7 +142,13 @@ func routes(logger *slog.Logger, cfg *config.Config, db *store.DB, provider ai.P
Recall: tools.NewRecallTool(db, provider, cfg.Search, activeProjects), Recall: tools.NewRecallTool(db, provider, cfg.Search, activeProjects),
Summarize: tools.NewSummarizeTool(db, provider, cfg.Search, activeProjects), Summarize: tools.NewSummarizeTool(db, provider, cfg.Search, activeProjects),
Links: tools.NewLinksTool(db, provider, cfg.Search), Links: tools.NewLinksTool(db, provider, cfg.Search),
Backfill: tools.NewBackfillTool(db, provider, activeProjects, logger), Backfill: tools.NewBackfillTool(db, provider, activeProjects, logger),
Reparse: tools.NewReparseMetadataTool(db, provider, cfg.Capture, activeProjects, logger),
Household: tools.NewHouseholdTool(db),
Maintenance: tools.NewMaintenanceTool(db),
Calendar: tools.NewCalendarTool(db),
Meals: tools.NewMealsTool(db),
CRM: tools.NewCRMTool(db),
} }
mcpHandler := mcpserver.New(cfg.MCP, toolSet) mcpHandler := mcpserver.New(cfg.MCP, toolSet)

View File

@@ -24,7 +24,13 @@ type ToolSet struct {
Recall *tools.RecallTool Recall *tools.RecallTool
Summarize *tools.SummarizeTool Summarize *tools.SummarizeTool
Links *tools.LinksTool Links *tools.LinksTool
Backfill *tools.BackfillTool Backfill *tools.BackfillTool
Reparse *tools.ReparseMetadataTool
Household *tools.HouseholdTool
Maintenance *tools.MaintenanceTool
Calendar *tools.CalendarTool
Meals *tools.MealsTool
CRM *tools.CRMTool
} }
func New(cfg config.MCPConfig, toolSet ToolSet) http.Handler { func New(cfg config.MCPConfig, toolSet ToolSet) http.Handler {
@@ -123,6 +129,161 @@ func New(cfg config.MCPConfig, toolSet ToolSet) http.Handler {
Description: "Generate missing embeddings for stored thoughts using the active embedding model.", Description: "Generate missing embeddings for stored thoughts using the active embedding model.",
}, toolSet.Backfill.Handle) }, toolSet.Backfill.Handle)
addTool(server, &mcp.Tool{
Name: "reparse_thought_metadata",
Description: "Re-extract and normalize metadata for stored thoughts from their content.",
}, toolSet.Reparse.Handle)
// Household Knowledge
addTool(server, &mcp.Tool{
Name: "add_household_item",
Description: "Store a household fact (paint color, appliance details, measurement, document, etc.).",
}, toolSet.Household.AddItem)
addTool(server, &mcp.Tool{
Name: "search_household_items",
Description: "Search household items by name, category, or location.",
}, toolSet.Household.SearchItems)
addTool(server, &mcp.Tool{
Name: "get_household_item",
Description: "Retrieve a household item by id.",
}, toolSet.Household.GetItem)
addTool(server, &mcp.Tool{
Name: "add_vendor",
Description: "Add a service provider (plumber, electrician, landscaper, etc.).",
}, toolSet.Household.AddVendor)
addTool(server, &mcp.Tool{
Name: "list_vendors",
Description: "List household service vendors, optionally filtered by service type.",
}, toolSet.Household.ListVendors)
// Home Maintenance
addTool(server, &mcp.Tool{
Name: "add_maintenance_task",
Description: "Create a recurring or one-time home maintenance task.",
}, toolSet.Maintenance.AddTask)
addTool(server, &mcp.Tool{
Name: "log_maintenance",
Description: "Log completed maintenance work; automatically updates the task's next due date.",
}, toolSet.Maintenance.LogWork)
addTool(server, &mcp.Tool{
Name: "get_upcoming_maintenance",
Description: "List maintenance tasks due within the next N days.",
}, toolSet.Maintenance.GetUpcoming)
addTool(server, &mcp.Tool{
Name: "search_maintenance_history",
Description: "Search the maintenance log by task name, category, or date range.",
}, toolSet.Maintenance.SearchHistory)
// Family Calendar
addTool(server, &mcp.Tool{
Name: "add_family_member",
Description: "Add a family member to the household.",
}, toolSet.Calendar.AddMember)
addTool(server, &mcp.Tool{
Name: "list_family_members",
Description: "List all family members.",
}, toolSet.Calendar.ListMembers)
addTool(server, &mcp.Tool{
Name: "add_activity",
Description: "Schedule a one-time or recurring family activity.",
}, toolSet.Calendar.AddActivity)
addTool(server, &mcp.Tool{
Name: "get_week_schedule",
Description: "Get all activities scheduled for a given week.",
}, toolSet.Calendar.GetWeekSchedule)
addTool(server, &mcp.Tool{
Name: "search_activities",
Description: "Search activities by title, type, or family member.",
}, toolSet.Calendar.SearchActivities)
addTool(server, &mcp.Tool{
Name: "add_important_date",
Description: "Track a birthday, anniversary, deadline, or other important date.",
}, toolSet.Calendar.AddImportantDate)
addTool(server, &mcp.Tool{
Name: "get_upcoming_dates",
Description: "Get important dates coming up in the next N days.",
}, toolSet.Calendar.GetUpcomingDates)
// Meal Planning
addTool(server, &mcp.Tool{
Name: "add_recipe",
Description: "Save a recipe with ingredients and instructions.",
}, toolSet.Meals.AddRecipe)
addTool(server, &mcp.Tool{
Name: "search_recipes",
Description: "Search recipes by name, cuisine, tags, or ingredient.",
}, toolSet.Meals.SearchRecipes)
addTool(server, &mcp.Tool{
Name: "update_recipe",
Description: "Update an existing recipe.",
}, toolSet.Meals.UpdateRecipe)
addTool(server, &mcp.Tool{
Name: "create_meal_plan",
Description: "Set the meal plan for a week; replaces any existing plan for that week.",
}, toolSet.Meals.CreateMealPlan)
addTool(server, &mcp.Tool{
Name: "get_meal_plan",
Description: "Get the meal plan for a given week.",
}, toolSet.Meals.GetMealPlan)
addTool(server, &mcp.Tool{
Name: "generate_shopping_list",
Description: "Auto-generate a shopping list from the meal plan for a given week.",
}, toolSet.Meals.GenerateShoppingList)
// Professional CRM
addTool(server, &mcp.Tool{
Name: "add_professional_contact",
Description: "Add a professional contact to the CRM.",
}, toolSet.CRM.AddContact)
addTool(server, &mcp.Tool{
Name: "search_contacts",
Description: "Search professional contacts by name, company, title, notes, or tags.",
}, toolSet.CRM.SearchContacts)
addTool(server, &mcp.Tool{
Name: "log_interaction",
Description: "Log an interaction with a professional contact.",
}, toolSet.CRM.LogInteraction)
addTool(server, &mcp.Tool{
Name: "get_contact_history",
Description: "Get full history (interactions and opportunities) for a contact.",
}, toolSet.CRM.GetHistory)
addTool(server, &mcp.Tool{
Name: "create_opportunity",
Description: "Create a deal, project, or opportunity linked to a contact.",
}, toolSet.CRM.CreateOpportunity)
addTool(server, &mcp.Tool{
Name: "get_follow_ups_due",
Description: "List contacts with a follow-up date due within the next N days.",
}, toolSet.CRM.GetFollowUpsDue)
addTool(server, &mcp.Tool{
Name: "link_thought_to_contact",
Description: "Append a stored thought to a contact's notes.",
}, toolSet.CRM.LinkThought)
return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server return server
}, &mcp.StreamableHTTPOptions{ }, &mcp.StreamableHTTPOptions{

210
internal/store/calendar.go Normal file
View File

@@ -0,0 +1,210 @@
package store
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
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))
created := m
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
return ext.FamilyMember{}, fmt.Errorf("insert family member: %w", err)
}
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()
var members []ext.FamilyMember
for rows.Next() {
var m ext.FamilyMember
var relationship, notes *string
if err := rows.Scan(&m.ID, &m.Name, &relationship, &m.BirthDate, &notes, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("scan family member: %w", err)
}
m.Relationship = strVal(relationship)
m.Notes = strVal(notes)
members = append(members, m)
}
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))
created := a
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
return ext.Activity{}, fmt.Errorf("insert activity: %w", err)
}
return created, nil
}
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()
return scanActivities(rows)
}
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)))
}
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()
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))
created := d
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
return ext.ImportantDate{}, fmt.Errorf("insert important date: %w", err)
}
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)
// 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 d ext.ImportantDate
var memberID *uuid.UUID
var memberName, notes *string
if err := rows.Scan(&d.ID, &memberID, &memberName, &d.Title, &d.DateValue,
&d.RecurringYearly, &d.ReminderDaysBefore, &notes, &d.CreatedAt); err != nil {
return nil, fmt.Errorf("scan important date: %w", err)
}
d.FamilyMemberID = memberID
d.MemberName = strVal(memberName)
d.Notes = strVal(notes)
dates = append(dates, d)
}
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 a ext.Activity
var memberName, activityType, dayOfWeek, startTime, endTime, location, notes *string
if err := rows.Scan(
&a.ID, &a.FamilyMemberID, &memberName, &a.Title, &activityType,
&dayOfWeek, &startTime, &endTime,
&a.StartDate, &a.EndDate, &location, &notes, &a.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan activity: %w", err)
}
a.MemberName = strVal(memberName)
a.ActivityType = strVal(activityType)
a.DayOfWeek = strVal(dayOfWeek)
a.StartTime = strVal(startTime)
a.EndTime = strVal(endTime)
a.Location = strVal(location)
a.Notes = strVal(notes)
activities = append(activities, a)
}
return activities, rows.Err()
}

247
internal/store/crm.go Normal file
View File

@@ -0,0 +1,247 @@
package store
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
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{}
}
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
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
return ext.ProfessionalContact{}, fmt.Errorf("insert contact: %w", err)
}
return created, nil
}
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)))
}
q := `select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, 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()
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, notes, last_contacted, follow_up_date, created_at, updated_at
from professional_contacts where id = $1
`, id)
var c ext.ProfessionalContact
var company, title, email, phone, linkedInURL, howWeMet, notes *string
if err := row.Scan(&c.ID, &c.Name, &company, &title, &email, &phone,
&linkedInURL, &howWeMet, &c.Tags, &notes, &c.LastContacted, &c.FollowUpDate,
&c.CreatedAt, &c.UpdatedAt); err != nil {
return ext.ProfessionalContact{}, fmt.Errorf("get contact: %w", err)
}
c.Company = strVal(company)
c.Title = strVal(title)
c.Email = strVal(email)
c.Phone = strVal(phone)
c.LinkedInURL = strVal(linkedInURL)
c.HowWeMet = strVal(howWeMet)
c.Notes = strVal(notes)
if c.Tags == nil {
c.Tags = []string{}
}
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()
}
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
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
return ext.ContactInteraction{}, fmt.Errorf("insert interaction: %w", err)
}
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
}
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 i ext.ContactInteraction
var followUpNotes *string
if err := rows.Scan(&i.ID, &i.ContactID, &i.InteractionType, &i.OccurredAt, &i.Summary,
&i.FollowUpNeeded, &followUpNotes, &i.CreatedAt); err != nil {
return ext.ContactHistory{}, fmt.Errorf("scan interaction: %w", err)
}
i.FollowUpNotes = strVal(followUpNotes)
interactions = append(interactions, i)
}
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()
var opportunities []ext.Opportunity
for oppRows.Next() {
var o ext.Opportunity
var description, notes *string
if err := oppRows.Scan(&o.ID, &o.ContactID, &o.Title, &description, &o.Stage, &o.Value,
&o.ExpectedCloseDate, &notes, &o.CreatedAt, &o.UpdatedAt); err != nil {
return ext.ContactHistory{}, fmt.Errorf("scan opportunity: %w", err)
}
o.Description = strVal(description)
o.Notes = strVal(notes)
opportunities = append(opportunities, o)
}
if err := oppRows.Err(); err != nil {
return ext.ContactHistory{}, err
}
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))
created := o
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
return ext.Opportunity{}, fmt.Errorf("insert opportunity: %w", err)
}
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)
rows, err := db.pool.Query(ctx, `
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, 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)
}
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 c ext.ProfessionalContact
var company, title, email, phone, linkedInURL, howWeMet, notes *string
if err := rows.Scan(&c.ID, &c.Name, &company, &title, &email, &phone,
&linkedInURL, &howWeMet, &c.Tags, &notes, &c.LastContacted, &c.FollowUpDate,
&c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan contact: %w", err)
}
c.Company = strVal(company)
c.Title = strVal(title)
c.Email = strVal(email)
c.Phone = strVal(phone)
c.LinkedInURL = strVal(linkedInURL)
c.HowWeMet = strVal(howWeMet)
c.Notes = strVal(notes)
if c.Tags == nil {
c.Tags = []string{}
}
contacts = append(contacts, c)
}
return contacts, rows.Err()
}

15
internal/store/helpers.go Normal file
View File

@@ -0,0 +1,15 @@
package store
func nullStr(s string) *string {
if s == "" {
return nil
}
return &s
}
func strVal(s *string) string {
if s == nil {
return ""
}
return *s
}

150
internal/store/household.go Normal file
View File

@@ -0,0 +1,150 @@
package store
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
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)
}
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
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
return ext.HouseholdItem{}, fmt.Errorf("insert household item: %w", err)
}
return created, nil
}
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)))
}
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()
var items []ext.HouseholdItem
for rows.Next() {
var item ext.HouseholdItem
var detailsBytes []byte
var category, location, notes *string
if err := rows.Scan(&item.ID, &item.Name, &category, &location, &detailsBytes, &notes, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan household item: %w", err)
}
item.Category = strVal(category)
item.Location = strVal(location)
item.Notes = strVal(notes)
if err := json.Unmarshal(detailsBytes, &item.Details); err != nil {
item.Details = map[string]any{}
}
items = append(items, item)
}
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)
var item ext.HouseholdItem
var detailsBytes []byte
var category, location, notes *string
if err := row.Scan(&item.ID, &item.Name, &category, &location, &detailsBytes, &notes, &item.CreatedAt, &item.UpdatedAt); err != nil {
return ext.HouseholdItem{}, fmt.Errorf("get household item: %w", err)
}
item.Category = strVal(category)
item.Location = strVal(location)
item.Notes = strVal(notes)
if err := json.Unmarshal(detailsBytes, &item.Details); err != nil {
item.Details = map[string]any{}
}
return item, 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)
created := v
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
return ext.HouseholdVendor{}, fmt.Errorf("insert vendor: %w", err)
}
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"
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 v ext.HouseholdVendor
var serviceType, phone, email, website, notes *string
var lastUsed *time.Time
if err := rows.Scan(&v.ID, &v.Name, &serviceType, &phone, &email, &website, &notes, &v.Rating, &lastUsed, &v.CreatedAt); err != nil {
return nil, fmt.Errorf("scan vendor: %w", err)
}
v.ServiceType = strVal(serviceType)
v.Phone = strVal(phone)
v.Email = strVal(email)
v.Website = strVal(website)
v.Notes = strVal(notes)
v.LastUsed = lastUsed
vendors = append(vendors, v)
}
return vendors, rows.Err()
}

View File

@@ -0,0 +1,142 @@
package store
import (
"context"
"fmt"
"strings"
"time"
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))
created := t
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
return ext.MaintenanceTask{}, fmt.Errorf("insert maintenance task: %w", err)
}
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()
}
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
if err := row.Scan(&created.ID); err != nil {
return ext.MaintenanceLog{}, fmt.Errorf("insert maintenance log: %w", err)
}
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)
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()
return scanMaintenanceTasks(rows)
}
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)))
}
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()
var logs []ext.MaintenanceLogWithTask
for rows.Next() {
var l ext.MaintenanceLogWithTask
var performedBy, notes, nextAction, taskCategory *string
if err := rows.Scan(
&l.ID, &l.TaskID, &l.CompletedAt, &performedBy, &l.Cost, &notes, &nextAction,
&l.TaskName, &taskCategory,
); err != nil {
return nil, fmt.Errorf("scan maintenance log: %w", err)
}
l.PerformedBy = strVal(performedBy)
l.Notes = strVal(notes)
l.NextAction = strVal(nextAction)
l.TaskCategory = strVal(taskCategory)
logs = append(logs, l)
}
return logs, rows.Err()
}
func scanMaintenanceTasks(rows interface {
Next() bool
Scan(...any) error
Err() error
Close()
}) ([]ext.MaintenanceTask, error) {
defer rows.Close()
var tasks []ext.MaintenanceTask
for rows.Next() {
var t ext.MaintenanceTask
var category, notes *string
if err := rows.Scan(&t.ID, &t.Name, &category, &t.FrequencyDays, &t.LastCompleted, &t.NextDue, &t.Priority, &notes, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan maintenance task: %w", err)
}
t.Category = strVal(category)
t.Notes = strVal(notes)
tasks = append(tasks, t)
}
return tasks, rows.Err()
}

289
internal/store/meals.go Normal file
View File

@@ -0,0 +1,289 @@
package store
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
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{}
}
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
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
return ext.Recipe{}, fmt.Errorf("insert recipe: %w", err)
}
return created, nil
}
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)))
}
q := `select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, 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()
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, rating, notes, created_at, updated_at
from recipes where id = $1
`, id)
var r ext.Recipe
var cuisine, notes *string
var ingredientsBytes, instructionsBytes []byte
if err := row.Scan(&r.ID, &r.Name, &cuisine, &r.PrepTimeMinutes, &r.CookTimeMinutes, &r.Servings,
&ingredientsBytes, &instructionsBytes, &r.Tags, &r.Rating, &notes, &r.CreatedAt, &r.UpdatedAt); err != nil {
return ext.Recipe{}, fmt.Errorf("get recipe: %w", err)
}
r.Cuisine = strVal(cuisine)
r.Notes = strVal(notes)
if r.Tags == nil {
r.Tags = []string{}
}
if err := json.Unmarshal(ingredientsBytes, &r.Ingredients); err != nil {
r.Ingredients = []ext.Ingredient{}
}
if err := json.Unmarshal(instructionsBytes, &r.Instructions); err != nil {
r.Instructions = []string{}
}
return r, 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{}
}
_, 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)
}
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,
}
if err := row.Scan(&entry.ID, &entry.CreatedAt); err != nil {
return nil, fmt.Errorf("insert meal plan entry: %w", err)
}
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()
var entries []ext.MealPlanEntry
for rows.Next() {
var e ext.MealPlanEntry
var recipeName, customMeal, notes *string
if err := rows.Scan(&e.ID, &e.WeekStart, &e.DayOfWeek, &e.MealType, &e.RecipeID, &recipeName, &customMeal, &e.Servings, &notes, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("scan meal plan entry: %w", err)
}
e.RecipeName = strVal(recipeName)
e.CustomMeal = strVal(customMeal)
e.Notes = strVal(notes)
entries = append(entries, e)
}
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
}
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,
}
}
}
}
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)
}
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)
list := ext.ShoppingList{WeekStart: weekStart, Items: items}
if err := row.Scan(&list.ID, &list.CreatedAt, &list.UpdatedAt); err != nil {
return ext.ShoppingList{}, fmt.Errorf("upsert shopping list: %w", err)
}
return list, nil
}
func scanRecipeRow(rows interface{ Scan(...any) error }) (ext.Recipe, error) {
var r ext.Recipe
var cuisine, notes *string
var ingredientsBytes, instructionsBytes []byte
if err := rows.Scan(&r.ID, &r.Name, &cuisine, &r.PrepTimeMinutes, &r.CookTimeMinutes, &r.Servings,
&ingredientsBytes, &instructionsBytes, &r.Tags, &r.Rating, &notes, &r.CreatedAt, &r.UpdatedAt); err != nil {
return ext.Recipe{}, fmt.Errorf("scan recipe: %w", err)
}
r.Cuisine = strVal(cuisine)
r.Notes = strVal(notes)
if r.Tags == nil {
r.Tags = []string{}
}
if err := json.Unmarshal(ingredientsBytes, &r.Ingredients); err != nil {
r.Ingredients = []ext.Ingredient{}
}
if err := json.Unmarshal(instructionsBytes, &r.Instructions); err != nil {
r.Instructions = []string{}
}
return r, nil
}

View File

@@ -428,6 +428,57 @@ func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, li
return thoughts, nil return thoughts, nil
} }
func (db *DB) ListThoughtsForMetadataReparse(ctx context.Context, limit int, projectID *uuid.UUID, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
args := make([]any, 0, 3)
conditions := make([]string, 0, 4)
if !includeArchived {
conditions = append(conditions, "archived_at is null")
}
if projectID != nil {
args = append(args, *projectID)
conditions = append(conditions, fmt.Sprintf("project_id = $%d", len(args)))
}
if olderThanDays > 0 {
args = append(args, time.Now().Add(-time.Duration(olderThanDays)*24*time.Hour))
conditions = append(conditions, fmt.Sprintf("created_at < $%d", len(args)))
}
args = append(args, limit)
query := `
select guid, content, metadata, project_id, archived_at, created_at, updated_at
from thoughts
`
if len(conditions) > 0 {
query += " where " + strings.Join(conditions, " and ")
}
query += " order by created_at asc limit $" + fmt.Sprintf("%d", len(args))
rows, err := db.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list thoughts for metadata reparse: %w", err)
}
defer rows.Close()
thoughts := make([]thoughttypes.Thought, 0, limit)
for rows.Next() {
var thought thoughttypes.Thought
var metadataBytes []byte
if err := rows.Scan(&thought.ID, &thought.Content, &metadataBytes, &thought.ProjectID, &thought.ArchivedAt, &thought.CreatedAt, &thought.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan metadata-reparse thought: %w", err)
}
if err := json.Unmarshal(metadataBytes, &thought.Metadata); err != nil {
return nil, fmt.Errorf("decode metadata-reparse thought metadata: %w", err)
}
thoughts = append(thoughts, thought)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate metadata-reparse thoughts: %w", err)
}
return thoughts, nil
}
func (db *DB) UpsertEmbedding(ctx context.Context, thoughtID uuid.UUID, model string, embedding []float32) error { func (db *DB) UpsertEmbedding(ctx context.Context, thoughtID uuid.UUID, model string, embedding []float32) error {
_, err := db.pool.Exec(ctx, ` _, err := db.pool.Exec(ctx, `
insert into embeddings (thought_id, model, dim, embedding) insert into embeddings (thought_id, model, dim, embedding)

212
internal/tools/calendar.go Normal file
View File

@@ -0,0 +1,212 @@
package tools
import (
"context"
"strings"
"time"
"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"
)
type CalendarTool struct {
store *store.DB
}
func NewCalendarTool(db *store.DB) *CalendarTool {
return &CalendarTool{store: db}
}
// 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 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{}, errInvalidInput("name is required")
}
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
type ListFamilyMembersInput struct{}
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
}
// 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 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{}, errInvalidInput("title is required")
}
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
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"`
}
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
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"`
}
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
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"`
}
func (t *CalendarTool) AddImportantDate(ctx context.Context, _ *mcp.CallToolRequest, in AddImportantDateInput) (*mcp.CallToolResult, AddImportantDateOutput, error) {
if strings.TrimSpace(in.Title) == "" {
return nil, AddImportantDateOutput{}, errInvalidInput("title is required")
}
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
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"`
}
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
}

232
internal/tools/crm.go Normal file
View File

@@ -0,0 +1,232 @@
package tools
import (
"context"
"fmt"
"strings"
"time"
"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"
)
type CRMTool struct {
store *store.DB
}
func NewCRMTool(db *store.DB) *CRMTool {
return &CRMTool{store: db}
}
// 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 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{}, errInvalidInput("name is required")
}
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
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"`
}
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
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"`
}
func (t *CRMTool) LogInteraction(ctx context.Context, _ *mcp.CallToolRequest, in LogInteractionInput) (*mcp.CallToolResult, LogInteractionOutput, error) {
if strings.TrimSpace(in.Summary) == "" {
return nil, LogInteractionOutput{}, errInvalidInput("summary is required")
}
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
type GetContactHistoryInput struct {
ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
}
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
}
// 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 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{}, errInvalidInput("title is required")
}
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
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"`
}
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
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"`
}
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 {
return nil, LinkThoughtToContactOutput{}, fmt.Errorf("thought not found: %w", 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 {
return nil, LinkThoughtToContactOutput{}, err
}
return nil, LinkThoughtToContactOutput{Contact: contact}, nil
}

151
internal/tools/household.go Normal file
View File

@@ -0,0 +1,151 @@
package tools
import (
"context"
"strings"
"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"
)
type HouseholdTool struct {
store *store.DB
}
func NewHouseholdTool(db *store.DB) *HouseholdTool {
return &HouseholdTool{store: db}
}
// 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 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{}, errInvalidInput("name is required")
}
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
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"`
}
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
type GetHouseholdItemInput struct {
ID uuid.UUID `json:"id" jsonschema:"item id"`
}
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
}
// 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 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{}, errInvalidInput("name is required")
}
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
type ListVendorsInput struct {
ServiceType string `json:"service_type,omitempty" jsonschema:"filter by service type"`
}
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
}

View File

@@ -0,0 +1,137 @@
package tools
import (
"context"
"strings"
"time"
"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"
)
type MaintenanceTool struct {
store *store.DB
}
func NewMaintenanceTool(db *store.DB) *MaintenanceTool {
return &MaintenanceTool{store: db}
}
// 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 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{}, errInvalidInput("name is required")
}
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
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"`
}
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
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"`
}
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
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"`
}
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
}

210
internal/tools/meals.go Normal file
View File

@@ -0,0 +1,210 @@
package tools
import (
"context"
"strings"
"time"
"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"
)
type MealsTool struct {
store *store.DB
}
func NewMealsTool(db *store.DB) *MealsTool {
return &MealsTool{store: db}
}
// 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 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{}, errInvalidInput("name is required")
}
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
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"`
}
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
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"`
}
func (t *MealsTool) UpdateRecipe(ctx context.Context, _ *mcp.CallToolRequest, in UpdateRecipeInput) (*mcp.CallToolResult, UpdateRecipeOutput, error) {
if strings.TrimSpace(in.Name) == "" {
return nil, UpdateRecipeOutput{}, errInvalidInput("name is required")
}
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
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"`
}
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
type GetMealPlanInput struct {
WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to retrieve"`
}
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
}
// generate_shopping_list
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"`
}
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
}

View File

@@ -0,0 +1,163 @@
package tools
import (
"context"
"log/slog"
"reflect"
"sync"
"time"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"golang.org/x/sync/semaphore"
"git.warky.dev/wdevs/amcs/internal/ai"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/metadata"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
const metadataReparseConcurrency = 4
type ReparseMetadataTool struct {
store *store.DB
provider ai.Provider
capture config.CaptureConfig
sessions *session.ActiveProjects
logger *slog.Logger
}
type ReparseMetadataInput struct {
Project string `json:"project,omitempty" jsonschema:"optional project name or id to scope the reparse"`
Limit int `json:"limit,omitempty" jsonschema:"maximum number of thoughts to process in one call; defaults to 100"`
IncludeArchived bool `json:"include_archived,omitempty" jsonschema:"whether to include archived thoughts; defaults to false"`
OlderThanDays int `json:"older_than_days,omitempty" jsonschema:"only reparse thoughts older than N days; 0 means no restriction"`
DryRun bool `json:"dry_run,omitempty" jsonschema:"report counts without updating metadata"`
}
type ReparseMetadataFailure struct {
ID string `json:"id"`
Error string `json:"error"`
}
type ReparseMetadataOutput struct {
Scanned int `json:"scanned"`
Reparsed int `json:"reparsed"`
Normalized int `json:"normalized"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
DryRun bool `json:"dry_run"`
Failures []ReparseMetadataFailure `json:"failures,omitempty"`
}
func NewReparseMetadataTool(db *store.DB, provider ai.Provider, capture config.CaptureConfig, sessions *session.ActiveProjects, logger *slog.Logger) *ReparseMetadataTool {
return &ReparseMetadataTool{store: db, provider: provider, capture: capture, sessions: sessions, logger: logger}
}
func (t *ReparseMetadataTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in ReparseMetadataInput) (*mcp.CallToolResult, ReparseMetadataOutput, error) {
limit := in.Limit
if limit <= 0 {
limit = 100
}
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil {
return nil, ReparseMetadataOutput{}, err
}
var projectID *uuid.UUID
if project != nil {
projectID = &project.ID
}
thoughts, err := t.store.ListThoughtsForMetadataReparse(ctx, limit, projectID, in.IncludeArchived, in.OlderThanDays)
if err != nil {
return nil, ReparseMetadataOutput{}, err
}
out := ReparseMetadataOutput{
Scanned: len(thoughts),
DryRun: in.DryRun,
}
if in.DryRun || len(thoughts) == 0 {
return nil, out, nil
}
start := time.Now()
sem := semaphore.NewWeighted(metadataReparseConcurrency)
var mu sync.Mutex
var wg sync.WaitGroup
for _, thought := range thoughts {
if ctx.Err() != nil {
break
}
if err := sem.Acquire(ctx, 1); err != nil {
break
}
wg.Add(1)
go func(thought thoughttypes.Thought) {
defer wg.Done()
defer sem.Release(1)
normalizedCurrent := metadata.Normalize(thought.Metadata, t.capture)
extracted, extractErr := t.provider.ExtractMetadata(ctx, thought.Content)
normalizedTarget := normalizedCurrent
if extractErr != nil {
mu.Lock()
out.Normalized++
mu.Unlock()
t.logger.Warn("metadata reparse extract failed, using normalized existing metadata", slog.String("thought_id", thought.ID.String()), slog.String("error", extractErr.Error()))
} else {
normalizedTarget = metadata.Normalize(extracted, t.capture)
mu.Lock()
out.Reparsed++
mu.Unlock()
}
if metadataEqual(thought.Metadata, normalizedTarget) {
mu.Lock()
out.Skipped++
mu.Unlock()
return
}
if _, updateErr := t.store.UpdateThought(ctx, thought.ID, thought.Content, nil, "", normalizedTarget, thought.ProjectID); updateErr != nil {
mu.Lock()
out.Failures = append(out.Failures, ReparseMetadataFailure{ID: thought.ID.String(), Error: updateErr.Error()})
mu.Unlock()
t.logger.Warn("metadata reparse update failed", slog.String("thought_id", thought.ID.String()), slog.String("error", updateErr.Error()))
return
}
mu.Lock()
out.Updated++
mu.Unlock()
}(thought)
}
wg.Wait()
out.Failed = len(out.Failures)
t.logger.Info("metadata reparse completed",
slog.Int("scanned", out.Scanned),
slog.Int("reparsed", out.Reparsed),
slog.Int("normalized", out.Normalized),
slog.Int("updated", out.Updated),
slog.Int("skipped", out.Skipped),
slog.Int("failed", out.Failed),
slog.Duration("duration", time.Since(start)),
)
return nil, out, nil
}
func metadataEqual(a, b thoughttypes.ThoughtMetadata) bool {
return reflect.DeepEqual(a, b)
}

View File

@@ -0,0 +1,215 @@
package types
import (
"time"
"github.com/google/uuid"
)
// Household Knowledge
type HouseholdItem struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Category string `json:"category,omitempty"`
Location string `json:"location,omitempty"`
Details map[string]any `json:"details"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type HouseholdVendor struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
ServiceType string `json:"service_type,omitempty"`
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"`
LastUsed *time.Time `json:"last_used,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Home Maintenance
type MaintenanceTask struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Category string `json:"category,omitempty"`
FrequencyDays *int `json:"frequency_days,omitempty"`
LastCompleted *time.Time `json:"last_completed,omitempty"`
NextDue *time.Time `json:"next_due,omitempty"`
Priority string `json:"priority"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type MaintenanceLog struct {
ID uuid.UUID `json:"id"`
TaskID uuid.UUID `json:"task_id"`
CompletedAt time.Time `json:"completed_at"`
PerformedBy string `json:"performed_by,omitempty"`
Cost *float64 `json:"cost,omitempty"`
Notes string `json:"notes,omitempty"`
NextAction string `json:"next_action,omitempty"`
}
type MaintenanceLogWithTask struct {
MaintenanceLog
TaskName string `json:"task_name"`
TaskCategory string `json:"task_category,omitempty"`
}
// Family Calendar
type FamilyMember struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Relationship string `json:"relationship,omitempty"`
BirthDate *time.Time `json:"birth_date,omitempty"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Activity struct {
ID uuid.UUID `json:"id"`
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty"`
MemberName string `json:"member_name,omitempty"`
Title string `json:"title"`
ActivityType string `json:"activity_type,omitempty"`
DayOfWeek string `json:"day_of_week,omitempty"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
StartDate *time.Time `json:"start_date,omitempty"`
EndDate *time.Time `json:"end_date,omitempty"`
Location string `json:"location,omitempty"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ImportantDate struct {
ID uuid.UUID `json:"id"`
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty"`
MemberName string `json:"member_name,omitempty"`
Title string `json:"title"`
DateValue time.Time `json:"date_value"`
RecurringYearly bool `json:"recurring_yearly"`
ReminderDaysBefore int `json:"reminder_days_before"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Meal Planning
type Ingredient struct {
Name string `json:"name"`
Quantity string `json:"quantity,omitempty"`
Unit string `json:"unit,omitempty"`
}
type Recipe struct {
ID uuid.UUID `json:"id"`
Name string `json:"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 []Ingredient `json:"ingredients"`
Instructions []string `json:"instructions"`
Tags []string `json:"tags"`
Rating *int `json:"rating,omitempty"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type MealPlanEntry struct {
ID uuid.UUID `json:"id"`
WeekStart time.Time `json:"week_start"`
DayOfWeek string `json:"day_of_week"`
MealType string `json:"meal_type"`
RecipeID *uuid.UUID `json:"recipe_id,omitempty"`
RecipeName string `json:"recipe_name,omitempty"`
CustomMeal string `json:"custom_meal,omitempty"`
Servings *int `json:"servings,omitempty"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type MealPlanInput struct {
DayOfWeek string `json:"day_of_week" jsonschema:"day of week (monday-sunday)"`
MealType string `json:"meal_type" jsonschema:"one of: breakfast, lunch, dinner, snack"`
RecipeID *uuid.UUID `json:"recipe_id,omitempty" jsonschema:"optional recipe id"`
CustomMeal string `json:"custom_meal,omitempty" jsonschema:"optional free-text meal description when no recipe"`
Servings *int `json:"servings,omitempty"`
Notes string `json:"notes,omitempty"`
}
type ShoppingItem struct {
Name string `json:"name"`
Quantity string `json:"quantity,omitempty"`
Unit string `json:"unit,omitempty"`
Purchased bool `json:"purchased"`
RecipeID *uuid.UUID `json:"recipe_id,omitempty"`
}
type ShoppingList struct {
ID uuid.UUID `json:"id"`
WeekStart time.Time `json:"week_start"`
Items []ShoppingItem `json:"items"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Professional CRM
type ProfessionalContact struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Company string `json:"company,omitempty"`
Title string `json:"title,omitempty"`
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"`
Notes string `json:"notes,omitempty"`
LastContacted *time.Time `json:"last_contacted,omitempty"`
FollowUpDate *time.Time `json:"follow_up_date,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ContactInteraction struct {
ID uuid.UUID `json:"id"`
ContactID uuid.UUID `json:"contact_id"`
InteractionType string `json:"interaction_type"`
OccurredAt time.Time `json:"occurred_at"`
Summary string `json:"summary"`
FollowUpNeeded bool `json:"follow_up_needed"`
FollowUpNotes string `json:"follow_up_notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Opportunity struct {
ID uuid.UUID `json:"id"`
ContactID *uuid.UUID `json:"contact_id,omitempty"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Stage string `json:"stage"`
Value *float64 `json:"value,omitempty"`
ExpectedCloseDate *time.Time `json:"expected_close_date,omitempty"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ContactHistory struct {
Contact ProfessionalContact `json:"contact"`
Interactions []ContactInteraction `json:"interactions"`
Opportunities []Opportunity `json:"opportunities"`
}

View File

@@ -1,7 +0,0 @@
-- Grant these permissions to the database role used by the application.
-- Replace amcs_user with the actual role in your deployment before applying.
grant ALL ON TABLE public.thoughts to amcs;
grant ALL ON TABLE public.projects to amcs;
grant ALL ON TABLE public.thought_links to amcs;
grant ALL ON TABLE public.embeddings to amcs;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO amcs;

View File

@@ -0,0 +1,42 @@
-- Extension 1: Household Knowledge Base
-- Stores household facts and vendor contacts (single-user, no RLS)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TABLE IF NOT EXISTS household_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
category TEXT,
location TEXT,
details JSONB NOT NULL DEFAULT '{}',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS household_vendors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
service_type TEXT,
phone TEXT,
email TEXT,
website TEXT,
notes TEXT,
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
last_used DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_household_items_category ON household_items(category);
CREATE INDEX IF NOT EXISTS idx_household_vendors_service ON household_vendors(service_type);
DROP TRIGGER IF EXISTS update_household_items_updated_at ON household_items;
CREATE TRIGGER update_household_items_updated_at
BEFORE UPDATE ON household_items
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,56 @@
-- Extension 2: Home Maintenance Tracker
-- Tracks recurring maintenance tasks and logs completed work (single-user, no RLS)
CREATE TABLE IF NOT EXISTS maintenance_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
category TEXT,
frequency_days INTEGER,
last_completed TIMESTAMPTZ,
next_due TIMESTAMPTZ,
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS maintenance_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES maintenance_tasks(id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
performed_by TEXT,
cost DECIMAL(10, 2),
notes TEXT,
next_action TEXT
);
CREATE INDEX IF NOT EXISTS idx_maintenance_tasks_next_due ON maintenance_tasks(next_due);
CREATE INDEX IF NOT EXISTS idx_maintenance_logs_task ON maintenance_logs(task_id, completed_at DESC);
DROP TRIGGER IF EXISTS update_maintenance_tasks_updated_at ON maintenance_tasks;
CREATE TRIGGER update_maintenance_tasks_updated_at
BEFORE UPDATE ON maintenance_tasks
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE FUNCTION update_task_after_maintenance_log()
RETURNS TRIGGER AS $$
DECLARE
task_frequency INTEGER;
BEGIN
SELECT frequency_days INTO task_frequency FROM maintenance_tasks WHERE id = NEW.task_id;
UPDATE maintenance_tasks
SET last_completed = NEW.completed_at,
next_due = CASE
WHEN task_frequency IS NOT NULL THEN NEW.completed_at + (task_frequency || ' days')::INTERVAL
ELSE NULL
END,
updated_at = now()
WHERE id = NEW.task_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_task_after_log ON maintenance_logs;
CREATE TRIGGER update_task_after_log
AFTER INSERT ON maintenance_logs
FOR EACH ROW EXECUTE FUNCTION update_task_after_maintenance_log();

View File

@@ -0,0 +1,42 @@
-- Extension 3: Family Calendar
-- Multi-person family scheduling (single-user, no RLS)
CREATE TABLE IF NOT EXISTS family_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
relationship TEXT,
birth_date DATE,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
family_member_id UUID REFERENCES family_members(id) ON DELETE SET NULL,
title TEXT NOT NULL,
activity_type TEXT,
day_of_week TEXT,
start_time TIME,
end_time TIME,
start_date DATE,
end_date DATE,
location TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS important_dates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
family_member_id UUID REFERENCES family_members(id) ON DELETE SET NULL,
title TEXT NOT NULL,
date_value DATE NOT NULL,
recurring_yearly BOOLEAN NOT NULL DEFAULT false,
reminder_days_before INTEGER NOT NULL DEFAULT 7,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_activities_dow ON activities(day_of_week);
CREATE INDEX IF NOT EXISTS idx_activities_member ON activities(family_member_id);
CREATE INDEX IF NOT EXISTS idx_activities_dates ON activities(start_date, end_date);
CREATE INDEX IF NOT EXISTS idx_important_dates_date ON important_dates(date_value);

View File

@@ -0,0 +1,54 @@
-- Extension 4: Meal Planning
-- Recipes, weekly meal plans, and shopping lists (single-user, no RLS)
CREATE TABLE IF NOT EXISTS recipes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
cuisine TEXT,
prep_time_minutes INTEGER,
cook_time_minutes INTEGER,
servings INTEGER,
ingredients JSONB NOT NULL DEFAULT '[]',
instructions JSONB NOT NULL DEFAULT '[]',
tags TEXT[] NOT NULL DEFAULT '{}',
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS meal_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
week_start DATE NOT NULL,
day_of_week TEXT NOT NULL,
meal_type TEXT NOT NULL CHECK (meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')),
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
custom_meal TEXT,
servings INTEGER,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS shopping_lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
week_start DATE NOT NULL UNIQUE,
items JSONB NOT NULL DEFAULT '[]',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_recipes_cuisine ON recipes(cuisine);
CREATE INDEX IF NOT EXISTS idx_recipes_tags ON recipes USING GIN (tags);
CREATE INDEX IF NOT EXISTS idx_meal_plans_week ON meal_plans(week_start);
CREATE INDEX IF NOT EXISTS idx_shopping_lists_week ON shopping_lists(week_start);
DROP TRIGGER IF EXISTS update_recipes_updated_at ON recipes;
CREATE TRIGGER update_recipes_updated_at
BEFORE UPDATE ON recipes
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_shopping_lists_updated_at ON shopping_lists;
CREATE TRIGGER update_shopping_lists_updated_at
BEFORE UPDATE ON shopping_lists
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,71 @@
-- Extension 5: Professional CRM
-- Contacts, interaction logs, and opportunities (single-user, no RLS)
CREATE TABLE IF NOT EXISTS professional_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
company TEXT,
title TEXT,
email TEXT,
phone TEXT,
linkedin_url TEXT,
how_we_met TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
notes TEXT,
last_contacted TIMESTAMPTZ,
follow_up_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS contact_interactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contact_id UUID NOT NULL REFERENCES professional_contacts(id) ON DELETE CASCADE,
interaction_type TEXT NOT NULL CHECK (interaction_type IN ('meeting', 'email', 'call', 'coffee', 'event', 'linkedin', 'other')),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
summary TEXT NOT NULL,
follow_up_needed BOOLEAN NOT NULL DEFAULT false,
follow_up_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS opportunities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contact_id UUID REFERENCES professional_contacts(id) ON DELETE SET NULL,
title TEXT NOT NULL,
description TEXT,
stage TEXT NOT NULL DEFAULT 'identified' CHECK (stage IN ('identified', 'in_conversation', 'proposal', 'negotiation', 'won', 'lost')),
value DECIMAL(12, 2),
expected_close_date DATE,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_contacts_last_contacted ON professional_contacts(last_contacted);
CREATE INDEX IF NOT EXISTS idx_contacts_follow_up ON professional_contacts(follow_up_date) WHERE follow_up_date IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_interactions_contact ON contact_interactions(contact_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_opportunities_stage ON opportunities(stage);
DROP TRIGGER IF EXISTS update_professional_contacts_updated_at ON professional_contacts;
CREATE TRIGGER update_professional_contacts_updated_at
BEFORE UPDATE ON professional_contacts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_opportunities_updated_at ON opportunities;
CREATE TRIGGER update_opportunities_updated_at
BEFORE UPDATE ON opportunities
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE OR REPLACE FUNCTION update_last_contacted()
RETURNS TRIGGER AS $$
BEGIN
UPDATE professional_contacts SET last_contacted = NEW.occurred_at WHERE id = NEW.contact_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_contact_last_contacted ON contact_interactions;
CREATE TRIGGER update_contact_last_contacted
AFTER INSERT ON contact_interactions
FOR EACH ROW EXECUTE FUNCTION update_last_contacted();

View File

@@ -0,0 +1,31 @@
-- Grant these permissions to the database role used by the application.
-- Replace amcs with the actual role in your deployment before applying.
GRANT ALL ON TABLE public.thoughts TO amcs;
GRANT ALL ON TABLE public.projects TO amcs;
GRANT ALL ON TABLE public.thought_links TO amcs;
GRANT ALL ON TABLE public.embeddings TO amcs;
-- Household Knowledge (011)
GRANT ALL ON TABLE public.household_items TO amcs;
GRANT ALL ON TABLE public.household_vendors TO amcs;
-- Home Maintenance (012)
GRANT ALL ON TABLE public.maintenance_tasks TO amcs;
GRANT ALL ON TABLE public.maintenance_logs TO amcs;
-- Family Calendar (013)
GRANT ALL ON TABLE public.family_members TO amcs;
GRANT ALL ON TABLE public.activities TO amcs;
GRANT ALL ON TABLE public.important_dates TO amcs;
-- Meal Planning (014)
GRANT ALL ON TABLE public.recipes TO amcs;
GRANT ALL ON TABLE public.meal_plans TO amcs;
GRANT ALL ON TABLE public.shopping_lists TO amcs;
-- Professional CRM (015)
GRANT ALL ON TABLE public.professional_contacts TO amcs;
GRANT ALL ON TABLE public.contact_interactions TO amcs;
GRANT ALL ON TABLE public.opportunities TO amcs;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO amcs;