17 Commits

Author SHA1 Message Date
Hein 3d4e6d0939 feat(sqltypes): add nullable SQL types with automatic casting
CI / build-and-test (push) Successful in 1m54s
Release / release (push) Successful in 3m12s
* Introduced SqlNull type for nullable values with auto-casting.
* Implemented JSON, YAML, and XML marshaling/unmarshaling.
* Added specific types for common SQL types (e.g., SqlInt64, SqlString).
* Included utility functions for creating nullable types.
* Added SqlTimeStamp, SqlDate, and SqlTime types with custom formatting.
2026-07-15 12:55:15 +02:00
Hein f94fddddb1 feat(security): add support for query modes in TOTP provider
CI / build-and-test (push) Successful in 1m33s
* Introduced QueryMode to select between stored procedure and direct SQL execution.
* Implemented dbCapability to check for stored procedure existence.
* Added methods to DatabaseTwoFactorProvider for configuring table names and query modes.
* Created direct SQL implementations for TOTP operations to handle cases where stored procedures are not available.
* Updated existing TOTP methods to utilize the new query mode logic.
2026-07-15 12:51:09 +02:00
warkanum 631bb64109 Merge pull request 'Design and implement per-user tenancy' (#40) from issue-10-per-user-tenancy into main
CI / build-and-test (push) Successful in 1m40s
Reviewed-on: #40
2026-07-15 10:48:24 +00:00
Hein 4f8f2f4190 Merge branch 'main' of git.warky.dev:wdevs/amcs into issue-10-per-user-tenancy
CI / build-and-test (push) Successful in 1m39s
CI / build-and-test (pull_request) Successful in 2m3s
2026-07-15 12:48:10 +02:00
warkanum 1689167a7b Merge pull request 'AMCS: add duplicate audit report tool' (#38) from issue-25-duplicate-audit-cleanup-tools into main
CI / build-and-test (push) Successful in 1m23s
Reviewed-on: #38
2026-07-15 04:35:57 +00:00
warkanum 5cd81f325f Merge pull request 'Add webhook ingestion endpoint for external sources' (#39) from issue-8-webhook-ingestion into main
CI / build-and-test (push) Successful in 1m10s
Reviewed-on: #39
2026-07-15 04:35:20 +00:00
warkanum cd010fc7a1 docs: plan per-user tenancy model
CI / build-and-test (push) Successful in 2m30s
CI / build-and-test (pull_request) Successful in 2m35s
2026-07-15 05:22:22 +02:00
warkanum e3a4a3c5c7 fix: build UI assets in CI
CI / build-and-test (pull_request) Successful in 1m20s
CI / build-and-test (push) Successful in 1m23s
2026-07-15 05:14:09 +02:00
warkanum 4b3f0b1b55 feat: add per-user tenant scoping
CI / build-and-test (push) Failing after 1m53s
CI / build-and-test (pull_request) Failing after 1m43s
2026-07-15 04:27:44 +02:00
warkanum e459775740 feat: add webhook thought ingestion
CI / build-and-test (pull_request) Successful in 3m33s
CI / build-and-test (push) Successful in 3m37s
2026-07-15 04:27:09 +02:00
warkanum 5718685c40 Merge pull request 'Expose retrieval mode in query responses' (#37) from issue-14-expose-retrieval-mode into main
CI / build-and-test (push) Failing after 57s
Reviewed-on: #37
Reviewed-by: Warky <2+warkanum@noreply@warky.dev>
2026-07-14 14:44:14 +00:00
warkanum 5c899d1635 Merge pull request 'Link thoughts and learnings' (#36) from issue-32-link-thoughts-learnings into main
CI / build-and-test (push) Failing after 1m6s
Reviewed-on: #36
Reviewed-by: Warky <2+warkanum@noreply@warky.dev>
2026-07-14 14:42:05 +00:00
warkanum 8db2141d45 ci: build ui before go tests
CI / build-and-test (push) Successful in 7m10s
CI / build-and-test (pull_request) Successful in 7m13s
2026-07-14 16:32:00 +02:00
warkanum 3198600031 feat(tools): add duplicate audit report
CI / build-and-test (push) Failing after 1m24s
CI / build-and-test (pull_request) Failing after 59s
2026-07-14 16:28:06 +02:00
warkanum 81a3470407 feat(tools): link thoughts and learnings
CI / build-and-test (pull_request) Failing after 1m2s
CI / build-and-test (push) Failing after 3m10s
2026-07-14 15:18:57 +02:00
warkanum cfc78e0493 feat(tools): expose retrieval_mode in query-based tool responses
CI / build-and-test (push) Failing after 1m14s
CI / build-and-test (pull_request) Failing after 2m11s
Add a retrieval_mode field ("semantic" or "text") to the output of all
five query-driven tools so callers can distinguish vector search from
Postgres full-text fallback without inspecting server logs.

Tools updated: search_thoughts, recall_context, get_project_context,
summarize_thoughts, and related_thoughts (semantic neighbours only;
omitempty so field is absent when include_semantic is false or no query
is provided for the non-mandatory-query tools).

The shared semanticSearch helper now returns the mode as a third return
value. All callers updated; fixes two compile errors left by the
previous worker (summarize.go and links.go were not capturing the new
return).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 12:44:53 +02:00
warkanum c179e014ad feat(db): add project personas and skills tables
CI / build-and-test (push) Failing after 1m52s
* Introduce project_personas table with foreign keys to projects and agent_personas
* Add project_skills table with foreign key to projects and agent_skills
* Include override boolean field in agent_persona_skills and project_skills
* Update schema and migration files to reflect new tables and fields
* Enhance CORS handling to reflect request origin
2026-07-04 23:45:51 +02:00
128 changed files with 7364 additions and 790 deletions
+36
View File
@@ -18,6 +18,14 @@ jobs:
with: with:
go-version: '1.26' go-version: '1.26'
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Enable pnpm
run: corepack enable
- name: Cache Go modules - name: Cache Go modules
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -31,9 +39,37 @@ jobs:
- name: Download dependencies - name: Download dependencies
run: go mod download run: go mod download
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install pnpm
run: npm install -g pnpm
- name: Build UI
run: |
cd ui
pnpm install --frozen-lockfile
pnpm run build
- name: Tidy modules - name: Tidy modules
run: go mod tidy run: go mod tidy
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install pnpm
run: npm install -g pnpm
- name: Build UI
run: |
cd ui
pnpm install --frozen-lockfile
pnpm run build
- name: Run tests - name: Run tests
run: go test ./... run: go test ./...
+1
View File
@@ -36,3 +36,4 @@ ui/.svelte-kit/
internal/app/ui/dist/* internal/app/ui/dist/*
!internal/app/ui/dist/placeholder.txt !internal/app/ui/dist/placeholder.txt
.codex .codex
.worktrees/
+45 -6
View File
@@ -65,10 +65,47 @@ The AMCS directory is used to store configuration and code for the Avalon Memory
| `add_project_guardrail` | Link a guardrail to a project; pass `project` if client is stateless | | `add_project_guardrail` | Link a guardrail to a project; pass `project` if client is stateless |
| `remove_project_guardrail` | Unlink a guardrail from a project; pass `project` if client is stateless | | `remove_project_guardrail` | Unlink a guardrail from a project; pass `project` if client is stateless |
| `list_project_guardrails` | Guardrails for a project; pass `project` if client is stateless | | `list_project_guardrails` | Guardrails for a project; pass `project` if client is stateless |
| `bootstrap_world_model` | Validate and activate a project, then load effective skills, guardrails, personas, and bounded context |
| `add_project_persona` | Link a persona to a project and optionally make it the default |
| `remove_project_persona` | Unlink a persona from a project |
| `set_default_project_persona` | Select the default persona for a project |
| `list_project_personas` | List project personas and identify the default |
| `get_version_info` | Build version, commit, and date | | `get_version_info` | Build version, commit, and date |
| `describe_tools` | List all available MCP tools with names, descriptions, categories, and model-authored usage notes; call this at the start of a session to orient yourself | | `describe_tools` | List all available MCP tools with names, descriptions, categories, and model-authored usage notes; call this at the start of a session to orient yourself |
| `annotate_tool` | Persist your own usage notes for a specific tool; notes are returned by `describe_tools` in future sessions | | `annotate_tool` | Persist your own usage notes for a specific tool; notes are returned by `describe_tools` in future sessions |
## Webhook ingestion
External automation can create thoughts without speaking MCP by posting JSON to `POST /webhooks/thoughts`. The endpoint is protected by the same AMCS authentication middleware as MCP and file uploads, so pass one configured API key via `x-brain-key`, an authorization bearer token header, or another enabled auth method.
Example:
```bash
curl -X POST http://localhost:8080/webhooks/thoughts \
-H 'Content-Type: application/json' \
-H 'x-brain-key: <api-key>' \
-H 'Idempotency-Key: n8n-run-123' \
-d '{
"content": "External system observed build failure on main",
"project": "amcs",
"source": "n8n",
"type": "task",
"topics": ["ci", "webhook"],
"metadata": {"workflow": "ci-monitor", "run_id": "123"}
}'
```
Payload fields:
- `content` is required and becomes the thought content.
- `project` is optional; when present it must match an existing AMCS project.
- `source`, `type`, `topics`, `people`, `action_items`, and `dates_mentioned` are normalized into the standard thought metadata schema. Unknown `type` values fall back to `observation`.
- `metadata` or `source_metadata` may contain safe source-specific JSON values; unsupported values and overly deep objects are dropped rather than persisted.
- `idempotency_key` or the `Idempotency-Key` header can be supplied to make repeated webhook deliveries return the existing thought with `duplicate: true`.
- `external_id` is stored under `metadata.webhook.external_id` for source-side traceability.
Successful new ingestion returns `201` with the created thought. Duplicate idempotency-key delivery returns `200` and the previously created thought. Invalid JSON, missing content, missing/unknown projects, or unauthenticated requests are rejected before persistence. Metadata and embedding enrichment are queued after the thought is stored.
## Learnings ## Learnings
Learnings are curated, structured memory records for durable insights you want to keep distinct from raw thoughts. Use them for normalized lessons, decisions, and evidence-backed findings that should be easy to retrieve and review over time. Learnings are curated, structured memory records for durable insights you want to keep distinct from raw thoughts. Use them for normalized lessons, decisions, and evidence-backed findings that should be easy to retrieve and review over time.
@@ -216,9 +253,9 @@ Use `get_version_info` to retrieve the runtime build metadata:
## Agent Skills and Guardrails ## Agent Skills and Guardrails
Skills and guardrails are reusable agent behaviour instructions and constraints that can be attached to projects. Skills and guardrails are reusable agent behaviour instructions and constraints that can be attached to projects. Projects can also expose personas and a default persona through the world model.
**At the start of every project session, always call `list_project_skills` and `list_project_guardrails` first.** Use the returned skills and guardrails to guide agent behaviour for that project. Only generate or create new skills/guardrails if none are returned. If your MCP client does not preserve sessions across calls, pass `project` explicitly instead of relying on `set_active_project`. **At the start of every project session, read `amcs://world-model/intro` and call `bootstrap_world_model` with an explicit project.** Apply its skills, guardrails, and default persona before continuing. If your MCP client does not preserve sessions across calls, keep passing `project` explicitly.
### Skills ### Skills
@@ -243,10 +280,12 @@ Severity levels: `low`, `medium`, `high`, `critical`.
Link existing skills and guardrails to a project so they are automatically available when that project is active: Link existing skills and guardrails to a project so they are automatically available when that project is active:
```json ```json
{ "project": "my-project", "skill_id": "<uuid>" } { "project": "my-project", "skill_id": 42, "override": false }
{ "project": "my-project", "guardrail_id": "<uuid>" } { "project": "my-project", "guardrail_id": 17 }
``` ```
Skills are additive by default. Set `override: true` on a project-skill or persona-skill link only when that skill must replace a lower-precedence skill with the same name. World-model precedence is project, selected persona, then runtime. Guardrails are additive and later layers cannot weaken them.
## Configuration ## Configuration
Config is YAML-driven. Copy `configs/config.example.yaml` and set: Config is YAML-driven. Copy `configs/config.example.yaml` and set:
@@ -417,7 +456,7 @@ Returns `{"file": {...}, "uri": "amcs://files/<id>"}`. Pass `thought_id`/`projec
### MCP resources ### MCP resources
Stored files are also exposed as MCP resources at `amcs://files/{id}`. MCP clients can read raw binary content directly via `resources/read` without going through `load_file`. Stored files are also exposed as MCP resources at `amcs://files/{id}`. MCP clients can read raw binary content directly via `resources/read` without going through `load_file`. The startup guide is available at `amcs://world-model/intro`.
### HTTP upload and download ### HTTP upload and download
@@ -471,7 +510,7 @@ metadata_retry:
include_archived: false include_archived: false
``` ```
**Search fallback**: when no embeddings exist for the active model in scope, `search_thoughts`, `recall_context`, `get_project_context`, `summarize_thoughts`, and `related_thoughts` automatically fall back to Postgres full-text search so results are never silently empty. **Search fallback**: when no embeddings exist for the active model in scope, `search_thoughts`, `recall_context`, `get_project_context`, `summarize_thoughts`, and `related_thoughts` automatically fall back to Postgres full-text search so results are never silently empty. All five tools include a `retrieval_mode` field in their response (`"semantic"` or `"text"`) so callers can see which path was taken.
## Client Setup ## Client Setup
+30
View File
@@ -0,0 +1,30 @@
# World Model Planning
Project: AMCS
AMCS already exposes MCP initialization instructions from `llm/memory.md`, active-project tools, project-linked skills and guardrails, persona manifests, project context, and MCP file resources.
Recommended design:
- Keep a concise mandatory world-model bootstrap protocol in MCP initialization instructions.
- Add a readable static resource at `amcs://world-model/intro` containing the fuller introduction and project-selection protocol.
- Add one `bootstrap_world_model` tool. It accepts an explicit project name or ID, validates it, sets it active when the transport is stateful, and returns one bounded snapshot containing project identity, linked skills, linked guardrails, linked persona manifests/default persona, and recent project context.
- Never silently select or create a project. Return structured `project_not_found` or `project_ambiguous` errors with candidate projects; stateless clients must keep passing the project explicitly.
- Add project-to-persona associations because the current schema only associates skills and guardrails with projects. Support one optional default persona plus additional available personas. Return manifests at bootstrap and load full persona content on demand.
- Merge skills additively by default. Store an explicit `override` flag on project-skill and persona-skill association rows, not on the reusable skill itself. An overriding skill replaces lower-precedence skills with the same stable skill key; unrelated skills remain additive.
- Resolve precedence deterministically: project skills form the base, the selected persona is applied next, and explicit runtime/session skills are applied last. At each layer, `override: true` replaces the accumulated entry with the same key while `override: false` adds or de-duplicates it.
- Include a snapshot version and generation timestamp so clients can cache and refresh the world model safely.
Delivery phases:
1. Fix the existing `list_project_skills` SQL failure: `skillSelectCols` leaves `created_at` and other columns unqualified in a join.
2. Define the bootstrap response contract, size limits, precedence rules, and structured errors.
3. Add project-persona schema/store/tool support through DBML generation.
4. Add the intro resource and initialization instructions.
5. Implement and register `bootstrap_world_model` using existing project, skill, guardrail, persona, and context stores.
6. Add MCP lifecycle, stateful/stateless, missing/ambiguous project, payload-bound, and resource tests.
7. Add admin UI management for project personas/default persona and update client documentation.
Confirmed decision: skills are additive unless their association has `override: true`. Guardrails remain additive, use the stricter severity when duplicates occur, and cannot be weakened by persona or runtime context.
AMCS MCP writes and subsequent verification hung during this planning session, so this local log is the required fallback rather than a confirmed remote thought/plan.
+161
View File
@@ -0,0 +1,161 @@
# Per-user tenancy implementation plan
## Scope and current state
Gitea issue #10 asks AMCS to isolate memories by user, tenant, or workspace. The current branch already carries the first implementation pass, so this plan records the intended model and the remaining hardening work another worker should verify or finish.
Observed code paths:
- Authentication enters through `internal/auth/middleware.go`. API-key auth uses the configured header, defaulting to `x-brain-key`; bearer tokens can resolve through OAuth `TokenStore` or API keyring; HTTP Basic resolves OAuth client credentials.
- `internal/auth/middleware.go` writes both `auth.key_id` and `tenancy.tenant_key` into the request context. The tenant key is intentionally opaque and currently equals the authenticated key id or OAuth client id.
- `internal/tenancy/tenancy.go` exposes `WithTenantKey` and `KeyFromContext` only; callers should not infer user semantics from the tenant string.
- Schema already has `tenant_key` on `projects`, `thoughts`, `stored_files`, `learnings`, `plans`, and `chat_histories` in `schema/*.dbml`.
- `internal/store/tenancy.go` provides helper functions for appending tenant predicates to SQL.
- Tenant scoping is already present in project, thought, and stored-file store paths. Some other project-owned domains still need explicit tenant enforcement.
## Tenant/user identity source
Use the authenticated principal id as the tenant boundary:
1. API key: resolve token through `auth.Keyring.Lookup`; use returned key id as `tenant_key`.
2. OAuth bearer token: resolve token through `TokenStore.Lookup`; use returned client id/key id as `tenant_key`.
3. OAuth Basic client credentials: resolve through `OAuthRegistry.Lookup`; use returned client id as `tenant_key`.
4. Unauthenticated requests must not get tenant context and must not reach protected MCP/API handlers.
Do not store raw API keys or bearer tokens in tenant columns. Store only stable configured key ids/client ids. Yes, it is less flashy than inventing an account service before breakfast, but it keeps the trust boundary small and auditable.
## Required schema/model changes
Source-of-truth DBML changes:
- Add nullable `tenant_key text` to all user-owned tables:
- `projects`
- `thoughts`
- `stored_files`
- `learnings`
- `plans`
- `chat_histories`
- Add tenant indexes:
- single-column `tenant_key` for direct filtering
- `(tenant_key, name)` unique on `projects`, replacing global `projects.name` uniqueness
- `(tenant_key, project_id)` on `thoughts` for common project-scoped memory lookups
- Regenerate SQL migrations and generated models from DBML; do not hand-edit generated Go models except as a temporary debugging step.
Important follow-up: `agent_skills`, `agent_guardrails`, `agent_personas`, `agent_parts`, traits, and arcs are currently global catalogs. Keep them global unless product requirements say skills/personas are private per tenant. Project join tables inherit protection through tenant-scoped project ids, but direct join queries must verify the project belongs to the tenant.
## Query scoping strategy
All request-facing store methods that touch tenant-owned rows must include tenant predicates whenever `tenancy.KeyFromContext(ctx)` returns a key.
Rules:
- Inserts must populate `tenant_key` from context.
- Gets/updates/deletes by id or guid must add `and tenant_key = $n`.
- Lists/searches must add `tenant_key = $n` before other filters.
- Joins must scope the tenant-owned root table. Example: project summaries should count only thoughts that belong to the same tenant as the project, not merely thoughts with the same `project_id`.
- Background maintenance jobs must either run per tenant, carry tenant context explicitly, or intentionally operate cross-tenant with an internal-only code path documented in the job.
Already-scoped paths to keep:
- `internal/store/projects.go`: create/get/list/touch projects.
- `internal/store/thoughts.go`: create/list/get/update/delete/archive/search/stats/embedding repair paths.
- `internal/store/files.go`: insert/get/list stored files.
Paths needing audit/hardening:
- `internal/store/learnings.go`: `CreateLearning`, `GetLearning`, and `ListLearnings` should populate/filter by tenant.
- `internal/store/plans.go`: `CreatePlan`, `GetPlan`, `GetPlanDetail`, `UpdatePlan`, `DeletePlan`, `ListPlans`, dependency/related-plan operations, and plan skill/guardrail joins should scope to tenant-owned plans.
- `internal/store/chat_histories.go`: chat history create/get/list/update paths should populate/filter by tenant.
- `internal/store/thought_learning_links.go`: links traverse tenant-owned thoughts/learnings; queries should join and scope both sides or at least the tenant-owned root.
- `internal/store/skills.go` and `internal/store/project_personas.go`: project-scoped joins should verify the project is visible to the current tenant before returning linked global catalog records.
- ResolveSpec/admin CRUD endpoints exposing generated models must not bypass tenant-aware store methods. If they use direct generic model access, add middleware-level filter injection or disable tenant-owned tables from generic admin writes.
## Migration and backfill
Migration approach for existing single-tenant installs:
1. Add nullable `tenant_key` columns first; do not make them `not null` initially because existing deployments have historical rows without a principal.
2. Backfill existing rows to a configured default tenant only if the instance enables multi-tenant mode or defines `auth.default_tenant_key`. Otherwise leave `NULL` rows visible only to unauthenticated/internal single-tenant contexts.
3. Replace global project-name uniqueness with `(tenant_key, name)` uniqueness. For PostgreSQL, preserve legacy `NULL` semantics carefully: multiple null-tenant projects with the same name may be possible unless a partial unique index is added for null tenant rows.
4. Add indexes concurrently where practical for production-sized tables.
5. Document that after enabling auth-backed tenancy, legacy null-tenant data is not visible to authenticated tenants unless backfilled.
Recommended config addition:
- `auth.default_tenant_key` or `tenancy.default_key` for one-time/self-hosted backfill and development.
- Optional `tenancy.mode: single|authenticated` so operators can keep current single-user behavior deliberately instead of discovering isolation by accident. An accident in auth is just a breach with better branding.
## Authorization checks
Authorization is row ownership by tenant key:
- A request may only see or mutate rows whose `tenant_key` equals the authenticated tenant key.
- Cross-tenant ids/gids should behave as not found, not forbidden, to avoid existence leaks.
- Tenant key is server-derived only. Ignore any client-provided `tenant_key` fields on tool/API inputs.
- Project id references in create/update operations must be validated against the same tenant before use. This prevents attaching a new thought/file/plan to another tenant's project id.
- Relationship operations must verify both endpoints are in the same tenant before creating links.
- Global catalogs can be read across tenants only if intentionally shared; project-specific associations must be tenant-checked through the project.
## Affected endpoints/services/UI surfaces
Backend/MCP tools:
- Project tools: create/get/list/set active project.
- Thought tools: capture, retrieve, update, delete/archive, semantic search, text search, stats, metadata and embedding repair queues.
- File tools and binary file upload/download APIs.
- Learning tools and thought-learning link tools.
- Plan/task tools including dependencies, related plans, skills, and guardrails.
- Chat history/session persistence tools.
- Persona/skill/guardrail project-association tools.
HTTP/API boundaries:
- MCP SSE and streamable HTTP handlers must run behind auth middleware when auth is configured.
- Any non-MCP REST endpoints under the admin/API server must either use tenant-aware stores or explicitly be internal/admin-only.
- ResolveSpec admin CRUD views need tenant filter injection or table-level access restrictions for tenant-owned models.
UI:
- Project selector/list should naturally show tenant-scoped projects.
- Admin tables for projects/thoughts/files/learnings/plans/chat histories must not show `tenant_key` as an editable field.
- If a tenant switcher is ever added, it must map to a server-side authenticated principal or admin impersonation path, not a client-side query parameter. Obviously.
## Test cases
Minimum test matrix:
1. Auth middleware propagates tenant key for API-key header auth.
2. Auth middleware propagates tenant key for bearer token via OAuth token store.
3. Auth middleware propagates tenant key for HTTP Basic OAuth client credentials.
4. Tenant A and tenant B can create projects with the same name; each only lists its own project.
5. Tenant A cannot get/update/delete/archive Tenant B's thought by guid or numeric id.
6. Semantic and text search return only same-tenant thoughts, including project-filtered searches.
7. Stored file get/list/download is scoped; a file id from another tenant returns not found.
8. Learning create/list/get is scoped, including links to thoughts.
9. Plan create/list/get/update/delete and dependency/related-plan operations are scoped.
10. Project skill/persona/guardrail association reads verify the project belongs to the tenant.
11. Background metadata/embedding retry queues do not cross tenants unless intentionally internal.
12. Migration test covers legacy null-tenant rows and backfill to default tenant.
13. ResolveSpec/admin API tests prove tenant-owned resources are filtered or unavailable without admin override.
## Assumptions and blockers
Assumptions:
- The first tenancy boundary is authenticated principal id, not human account, org, or workspace. This is consistent with the current auth system and avoids adding an account model prematurely.
- Null `tenant_key` remains the compatibility path for existing single-tenant/internal flows.
- Global skills/personas/guardrails remain shared catalogs until a separate product decision makes them tenant-private.
Blockers/decisions needed:
- Decide whether legacy rows should be backfilled automatically to a configured default tenant during migration or left null until an operator runs an explicit backfill.
- Decide whether ResolveSpec admin endpoints are trusted admin-only or must enforce tenant predicates like MCP tools.
- Decide whether future OAuth subjects should distinguish user id from client id; the current implementation only has client/key id available.
## Implementation order
1. Finish tenant scoping audits for learnings, plans, chat histories, thought-learning links, and project catalog joins.
2. Add tests proving cross-tenant invisibility for each store/tool domain before widening coverage. Yes, tests first; future us has enough enemies.
3. Add config/backfill migration behavior and document operator steps.
4. Lock down or filter ResolveSpec/admin tenant-owned model access.
5. Run `make test` and `make build`; include exact results in the issue/PR comment.
+5 -5
View File
@@ -3,7 +3,8 @@ module git.warky.dev/wdevs/amcs
go 1.26.1 go 1.26.1
require ( require (
github.com/bitechdev/ResolveSpec v1.1.15 git.warky.dev/wdevs/relspecgo v1.0.62
github.com/bitechdev/ResolveSpec v1.1.26
github.com/google/jsonschema-go v0.4.3 github.com/google/jsonschema-go v0.4.3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
@@ -11,7 +12,7 @@ require (
github.com/pgvector/pgvector-go v0.3.0 github.com/pgvector/pgvector-go v0.3.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/uptrace/bun v1.2.18 github.com/uptrace/bun v1.2.18
github.com/uptrace/bun/dialect/pgdialect v1.2.16 github.com/uptrace/bun/dialect/pgdialect v1.2.18
github.com/uptrace/bun/driver/pgdriver v1.1.12 github.com/uptrace/bun/driver/pgdriver v1.1.12
github.com/uptrace/bunrouter v1.0.23 github.com/uptrace/bunrouter v1.0.23
golang.org/x/sync v0.20.0 golang.org/x/sync v0.20.0
@@ -46,7 +47,6 @@ require (
github.com/prometheus/procfs v0.20.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/redis/go-redis/v9 v9.19.0 // indirect github.com/redis/go-redis/v9 v9.19.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect github.com/segmentio/encoding v0.5.4 // indirect
@@ -62,8 +62,8 @@ require (
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/uptrace/bun/dialect/mssqldialect v1.2.16 // indirect github.com/uptrace/bun/dialect/mssqldialect v1.2.18 // indirect
github.com/uptrace/bun/dialect/sqlitedialect v1.2.16 // indirect github.com/uptrace/bun/dialect/sqlitedialect v1.2.18 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
+12 -10
View File
@@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
git.warky.dev/wdevs/relspecgo v1.0.62 h1:byBe2IlcwQRKsV5qXHfGITtfvGZewHlNstP+O8BGnns=
git.warky.dev/wdevs/relspecgo v1.0.62/go.mod h1:JpZBvui9dYc/QcD5TZ1wKab+4bUvPw5/rOLOA/h2F6A=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
@@ -34,8 +36,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bitechdev/ResolveSpec v1.1.15 h1:Bhy1ZWGUg9AJOhOLk5Di9ilOVKmZ40SkGIGDraAlti8= github.com/bitechdev/ResolveSpec v1.1.26 h1:/OYc1Mjcfm4Qxq8Xy5UY/32l5cSqkNe8ieeOqIEbK5o=
github.com/bitechdev/ResolveSpec v1.1.15/go.mod h1:GF51sMRCWbAyri2WNae3IZAFM/2s6DG6i3eTTrobbVs= github.com/bitechdev/ResolveSpec v1.1.26/go.mod h1:GF51sMRCWbAyri2WNae3IZAFM/2s6DG6i3eTTrobbVs=
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8= github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8=
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -292,12 +294,12 @@ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYm
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun v1.2.18 h1:3HnRcMfS6OBPMG1eSOzlbFJ/X/AyMEJb7rMxE6VQvDU= github.com/uptrace/bun v1.2.18 h1:3HnRcMfS6OBPMG1eSOzlbFJ/X/AyMEJb7rMxE6VQvDU=
github.com/uptrace/bun v1.2.18/go.mod h1:wNltaKJk4JtOt4SG5I5zmA7v0/Mzjh1+/S906Rayd3Y= github.com/uptrace/bun v1.2.18/go.mod h1:wNltaKJk4JtOt4SG5I5zmA7v0/Mzjh1+/S906Rayd3Y=
github.com/uptrace/bun/dialect/mssqldialect v1.2.16 h1:rKv0cKPNBviXadB/+2Y/UedA/c1JnwGzUWZkdN5FdSQ= github.com/uptrace/bun/dialect/mssqldialect v1.2.18 h1:nYzHoyJKJlIyl5i95Exi8ZTK8ooKWG+o3z3f404d/yQ=
github.com/uptrace/bun/dialect/mssqldialect v1.2.16/go.mod h1:J5U7tGKWDsx2Q7MwDZF2417jCdpD6yD/ZMFJcCR80bk= github.com/uptrace/bun/dialect/mssqldialect v1.2.18/go.mod h1:Su45Je7z66sfeZ3d1ZsnOQEK8xfzGgaMzBvtoE8yFhk=
github.com/uptrace/bun/dialect/pgdialect v1.2.16 h1:KFNZ0LxAyczKNfK/IJWMyaleO6eI9/Z5tUv3DE1NVL4= github.com/uptrace/bun/dialect/pgdialect v1.2.18 h1:IZ6nM2+OYrL8lkEAy7UkSEZvoa3vluTAUlZfPtlRB2k=
github.com/uptrace/bun/dialect/pgdialect v1.2.16/go.mod h1:IJdMeV4sLfh0LDUZl7TIxLI0LipF1vwTK3hBC7p5qLo= github.com/uptrace/bun/dialect/pgdialect v1.2.18/go.mod h1:Tqdf4QP1okrGYpXfodXvCOK6Ob1OOTwSaoAzCgBB3IU=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.16 h1:6wVAiYLj1pMibRthGwy4wDLa3D5AQo32Y8rvwPd8CQ0= github.com/uptrace/bun/dialect/sqlitedialect v1.2.18 h1:Z33SY/U++XK9uGWqS4h8OZVxfCXguIG+sU9cYq2PGFQ=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.16/go.mod h1:Z7+5qK8CGZkDQiPMu+LSdVuDuR1I5jcwtkB1Pi3F82E= github.com/uptrace/bun/dialect/sqlitedialect v1.2.18/go.mod h1:1MVOS/Ncy4FZbkJcgUFH6OqYoQinYNjkEwsmNQEXz2A=
github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w=
github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM= github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM=
github.com/uptrace/bun/driver/sqliteshim v1.2.16 h1:M6Dh5kkDWFbUWBrOsIE1g1zdZ5JbSytTD4piFRBOUAI= github.com/uptrace/bun/driver/sqliteshim v1.2.16 h1:M6Dh5kkDWFbUWBrOsIE1g1zdZ5JbSytTD4piFRBOUAI=
@@ -452,8 +454,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+25 -20
View File
@@ -194,26 +194,30 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
adminActions := newAdminActions(backfillTool, enrichmentRetryer, logger) adminActions := newAdminActions(backfillTool, enrichmentRetryer, logger)
toolSet := mcpserver.ToolSet{ toolSet := mcpserver.ToolSet{
Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool), Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool),
Search: tools.NewSearchTool(db, embeddings, cfg.Search, activeProjects), Search: tools.NewSearchTool(db, embeddings, cfg.Search, activeProjects),
List: tools.NewListTool(db, cfg.Search, activeProjects), List: tools.NewListTool(db, cfg.Search, activeProjects),
Stats: tools.NewStatsTool(db), Stats: tools.NewStatsTool(db),
Get: tools.NewGetTool(db), Get: tools.NewGetTool(db),
Update: tools.NewUpdateTool(db, embeddings, metadata, cfg.Capture, logger), Update: tools.NewUpdateTool(db, embeddings, metadata, cfg.Capture, logger),
Delete: tools.NewDeleteTool(db), Delete: tools.NewDeleteTool(db),
Archive: tools.NewArchiveTool(db), Archive: tools.NewArchiveTool(db),
Projects: tools.NewProjectsTool(db, activeProjects), DuplicateAudit: tools.NewDuplicateAuditTool(db, cfg.Search, activeProjects),
Version: tools.NewVersionTool(cfg.MCP.ServerName, info), Projects: tools.NewProjectsTool(db, activeProjects),
Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search), Version: tools.NewVersionTool(cfg.MCP.ServerName, info),
Plans: tools.NewPlansTool(db, activeProjects, cfg.Search), Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search),
Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects), Plans: tools.NewPlansTool(db, activeProjects, cfg.Search),
Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects), ProjectPersonas: tools.NewProjectPersonasTool(db, activeProjects),
Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects), WorldModel: tools.NewWorldModelTool(db, activeProjects),
Links: tools.NewLinksTool(db, embeddings, cfg.Search), ThoughtLearningLinks: tools.NewThoughtLearningLinksTool(db),
Files: filesTool, Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects),
Backfill: backfillTool, Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects),
Reparse: tools.NewReparseMetadataTool(db, bgMetadata, cfg.Capture, activeProjects, logger), Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects),
RetryMetadata: tools.NewRetryEnrichmentTool(enrichmentRetryer), Links: tools.NewLinksTool(db, embeddings, cfg.Search),
Files: filesTool,
Backfill: backfillTool,
Reparse: tools.NewReparseMetadataTool(db, bgMetadata, cfg.Capture, activeProjects, logger),
RetryMetadata: tools.NewRetryEnrichmentTool(enrichmentRetryer),
//Maintenance: tools.NewMaintenanceTool(db), //Maintenance: tools.NewMaintenanceTool(db),
Skills: tools.NewSkillsTool(db, activeProjects), Skills: tools.NewSkillsTool(db, activeProjects),
Personas: tools.NewAgentPersonasTool(db), Personas: tools.NewAgentPersonasTool(db),
@@ -235,6 +239,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
} }
mux.Handle("/files", authMiddleware(fileHandler(filesTool))) mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool))) mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
mux.Handle("/webhooks/thoughts", authMiddleware(newWebhookThoughtHandler(db, embeddings, cfg.Capture, enrichmentRetryer, backfillTool)))
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler()) mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger)) mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger))
mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger)) mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
@@ -28,9 +28,11 @@ func resolveSpecModels() []resolveSpecModel {
{schema: "public", entity: "plan_skills", model: generatedmodels.ModelPublicPlanSkills{}}, {schema: "public", entity: "plan_skills", model: generatedmodels.ModelPublicPlanSkills{}},
{schema: "public", entity: "plans", model: generatedmodels.ModelPublicPlans{}}, {schema: "public", entity: "plans", model: generatedmodels.ModelPublicPlans{}},
{schema: "public", entity: "project_guardrails", model: generatedmodels.ModelPublicProjectGuardrails{}}, {schema: "public", entity: "project_guardrails", model: generatedmodels.ModelPublicProjectGuardrails{}},
{schema: "public", entity: "project_personas", model: generatedmodels.ModelPublicProjectPersonas{}},
{schema: "public", entity: "project_skills", model: generatedmodels.ModelPublicProjectSkills{}}, {schema: "public", entity: "project_skills", model: generatedmodels.ModelPublicProjectSkills{}},
{schema: "public", entity: "projects", model: generatedmodels.ModelPublicProjects{}}, {schema: "public", entity: "projects", model: generatedmodels.ModelPublicProjects{}},
{schema: "public", entity: "stored_files", model: generatedmodels.ModelPublicStoredFiles{}}, {schema: "public", entity: "stored_files", model: generatedmodels.ModelPublicStoredFiles{}},
{schema: "public", entity: "thought_learning_links", model: generatedmodels.ModelPublicThoughtLearningLinks{}},
{schema: "public", entity: "thought_links", model: generatedmodels.ModelPublicThoughtLinks{}}, {schema: "public", entity: "thought_links", model: generatedmodels.ModelPublicThoughtLinks{}},
{schema: "public", entity: "thoughts", model: generatedmodels.ModelPublicThoughts{}}, {schema: "public", entity: "thoughts", model: generatedmodels.ModelPublicThoughts{}},
{schema: "public", entity: "tool_annotations", model: generatedmodels.ModelPublicToolAnnotations{}}, {schema: "public", entity: "tool_annotations", model: generatedmodels.ModelPublicToolAnnotations{}},
+214
View File
@@ -0,0 +1,214 @@
package app
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"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/store"
"git.warky.dev/wdevs/amcs/internal/tools"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
const maxWebhookBodyBytes = 1 << 20
type webhookThoughtRequest struct {
Content string `json:"content"`
Project string `json:"project,omitempty"`
Source string `json:"source,omitempty"`
Type string `json:"type,omitempty"`
Topics []string `json:"topics,omitempty"`
People []string `json:"people,omitempty"`
ActionItems []string `json:"action_items,omitempty"`
DatesMentioned []string `json:"dates_mentioned,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
SourceMetadata map[string]any `json:"source_metadata,omitempty"`
IDempotencyKey string `json:"idempotency_key,omitempty"`
ExternalID string `json:"external_id,omitempty"`
}
type webhookThoughtResponse struct {
Thought thoughttypes.Thought `json:"thought"`
Duplicate bool `json:"duplicate"`
WebhookMeta thoughttypes.WebhookMetadata `json:"webhook"`
}
type webhookThoughtHandler struct {
store *store.DB
embeddings *ai.EmbeddingRunner
capture config.CaptureConfig
retryer tools.MetadataQueuer
embedRetryer tools.EmbeddingQueuer
}
func newWebhookThoughtHandler(db *store.DB, embeddings *ai.EmbeddingRunner, capture config.CaptureConfig, retryer tools.MetadataQueuer, embedRetryer tools.EmbeddingQueuer) http.Handler {
return &webhookThoughtHandler{store: db, embeddings: embeddings, capture: capture, retryer: retryer, embedRetryer: embedRetryer}
}
func (h *webhookThoughtHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/webhooks/thoughts" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)
in, err := parseWebhookThoughtRequest(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
webhookMeta := buildWebhookMetadata(in, r.Header.Get("Idempotency-Key"), time.Now().UTC())
if webhookMeta.IDempotencyKey != "" {
if existing, err := h.store.GetThoughtByWebhookIDempotencyKey(r.Context(), webhookMeta.IDempotencyKey); err == nil {
writeWebhookThoughtResponse(w, http.StatusOK, webhookThoughtResponse{Thought: existing, Duplicate: true, WebhookMeta: webhookMeta})
return
}
}
projectID, err := h.resolveWebhookProject(r, in.Project)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
thought := thoughttypes.Thought{
Content: strings.TrimSpace(in.Content),
Metadata: normalizeWebhookThoughtMetadata(in, webhookMeta, h.capture),
ProjectID: projectID,
}
created, err := h.store.InsertThought(r.Context(), thought, h.embeddings.PrimaryModel())
if err != nil {
http.Error(w, "insert thought: "+err.Error(), http.StatusInternalServerError)
return
}
if projectID != nil {
_ = h.store.TouchProject(r.Context(), *projectID)
}
if h.retryer != nil {
h.retryer.QueueThought(created.ID)
}
if h.embedRetryer != nil {
h.embedRetryer.QueueThought(r.Context(), created.ID, created.Content)
}
writeWebhookThoughtResponse(w, http.StatusCreated, webhookThoughtResponse{Thought: created, WebhookMeta: webhookMeta})
}
func parseWebhookThoughtRequest(r *http.Request) (webhookThoughtRequest, error) {
if !strings.Contains(r.Header.Get("Content-Type"), "application/json") {
return webhookThoughtRequest{}, errors.New("webhook requires application/json")
}
defer r.Body.Close()
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
var in webhookThoughtRequest
if err := decoder.Decode(&in); err != nil {
return webhookThoughtRequest{}, err
}
if strings.TrimSpace(in.Content) == "" {
return webhookThoughtRequest{}, errors.New("content is required")
}
return in, nil
}
func (h *webhookThoughtHandler) resolveWebhookProject(r *http.Request, projectName string) (*int64, error) {
projectName = strings.TrimSpace(projectName)
if projectName == "" {
return nil, nil
}
project, err := h.store.GetProject(r.Context(), projectName)
if err != nil {
return nil, err
}
return &project.NumericID, nil
}
func buildWebhookMetadata(in webhookThoughtRequest, headerKey string, now time.Time) thoughttypes.WebhookMetadata {
sourceMetadata := in.SourceMetadata
if len(sourceMetadata) == 0 {
sourceMetadata = in.Metadata
}
return thoughttypes.WebhookMetadata{
ReceivedAt: now.Format(time.RFC3339),
IDempotencyKey: firstNonEmpty(in.IDempotencyKey, headerKey),
ExternalID: strings.TrimSpace(in.ExternalID),
SourceMetadata: sanitizeWebhookMetadata(sourceMetadata),
}
}
func normalizeWebhookThoughtMetadata(in webhookThoughtRequest, webhookMeta thoughttypes.WebhookMetadata, capture config.CaptureConfig) thoughttypes.ThoughtMetadata {
return metadata.Normalize(thoughttypes.ThoughtMetadata{
People: in.People,
ActionItems: in.ActionItems,
DatesMentioned: in.DatesMentioned,
Topics: in.Topics,
Type: in.Type,
Source: firstNonEmpty(in.Source, "webhook"),
Webhook: &webhookMeta,
}, capture)
}
func sanitizeWebhookMetadata(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for key, value := range in {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if sanitized, ok := sanitizeWebhookMetadataValue(value, 0); ok {
out[key] = sanitized
}
}
if len(out) == 0 {
return nil
}
return out
}
func sanitizeWebhookMetadataValue(value any, depth int) (any, bool) {
if depth > 3 {
return nil, false
}
switch v := value.(type) {
case nil, bool, float64, string:
return v, true
case []any:
if len(v) > 50 {
v = v[:50]
}
out := make([]any, 0, len(v))
for _, item := range v {
if sanitized, ok := sanitizeWebhookMetadataValue(item, depth+1); ok {
out = append(out, sanitized)
}
}
return out, true
case map[string]any:
if len(v) > 50 {
return nil, false
}
return sanitizeWebhookMetadata(v), true
default:
return nil, false
}
}
func writeWebhookThoughtResponse(w http.ResponseWriter, status int, out webhookThoughtResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(out)
}
+86
View File
@@ -0,0 +1,86 @@
package app
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.warky.dev/wdevs/amcs/internal/config"
)
func TestParseWebhookThoughtRequestRequiresJSON(t *testing.T) {
req := httptestRequest("text/plain", `{"content":"hello"}`)
_, err := parseWebhookThoughtRequest(req)
if err == nil {
t.Fatal("expected error for non-json content type")
}
}
func TestParseWebhookThoughtRequestRequiresContent(t *testing.T) {
req := httptestRequest("application/json", `{"source":"n8n"}`)
_, err := parseWebhookThoughtRequest(req)
if err == nil || !strings.Contains(err.Error(), "content is required") {
t.Fatalf("error = %v, want content required", err)
}
}
func TestBuildWebhookMetadataUsesHeaderIdempotencyAndSanitizesMetadata(t *testing.T) {
now := time.Date(2026, 7, 15, 4, 0, 0, 0, time.UTC)
got := buildWebhookMetadata(webhookThoughtRequest{
ExternalID: " ext-1 ",
Metadata: map[string]any{
"service": "n8n",
"unsafe": struct{}{},
"nested": map[string]any{"ok": true},
},
}, " key-1 ", now)
if got.IDempotencyKey != "key-1" {
t.Fatalf("IDempotencyKey = %q, want key-1", got.IDempotencyKey)
}
if got.ExternalID != "ext-1" {
t.Fatalf("ExternalID = %q, want ext-1", got.ExternalID)
}
if got.ReceivedAt != "2026-07-15T04:00:00Z" {
t.Fatalf("ReceivedAt = %q", got.ReceivedAt)
}
if _, ok := got.SourceMetadata["unsafe"]; ok {
t.Fatal("unsafe metadata value was not removed")
}
if got.SourceMetadata["service"] != "n8n" {
t.Fatalf("service metadata = %#v", got.SourceMetadata["service"])
}
}
func TestNormalizeWebhookThoughtMetadata(t *testing.T) {
webhookMeta := buildWebhookMetadata(webhookThoughtRequest{IDempotencyKey: "abc"}, "", time.Date(2026, 7, 15, 4, 0, 0, 0, time.UTC))
got := normalizeWebhookThoughtMetadata(webhookThoughtRequest{
Source: "github",
Type: "task",
Topics: []string{"ci", "ci", ""},
People: []string{" Sam "},
}, webhookMeta, config.CaptureConfig{})
if got.Source != "github" {
t.Fatalf("Source = %q, want github", got.Source)
}
if got.Type != "task" {
t.Fatalf("Type = %q, want task", got.Type)
}
if len(got.Topics) != 1 || got.Topics[0] != "ci" {
t.Fatalf("Topics = %#v, want [ci]", got.Topics)
}
if got.Webhook == nil || got.Webhook.IDempotencyKey != "abc" {
t.Fatalf("Webhook = %#v, want idempotency key abc", got.Webhook)
}
}
func httptestRequest(contentType, body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/webhooks/thoughts", strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
return req
}
+10 -5
View File
@@ -11,6 +11,7 @@ import (
"git.warky.dev/wdevs/amcs/internal/config" "git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/observability" "git.warky.dev/wdevs/amcs/internal/observability"
"git.warky.dev/wdevs/amcs/internal/requestip" "git.warky.dev/wdevs/amcs/internal/requestip"
"git.warky.dev/wdevs/amcs/internal/tenancy"
) )
type contextKey string type contextKey string
@@ -50,6 +51,10 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
) )
} }
} }
withTenant := func(ctx context.Context, keyID string) context.Context {
ctx = context.WithValue(ctx, keyIDContextKey, keyID)
return tenancy.WithTenantKey(ctx, keyID)
}
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
remoteAddr := requestip.FromRequest(r) remoteAddr := requestip.FromRequest(r)
@@ -63,7 +68,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
return return
} }
recordAccess(r, keyID) recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID))) next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return return
} }
} }
@@ -73,14 +78,14 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
if tokenStore != nil { if tokenStore != nil {
if keyID, ok := tokenStore.Lookup(bearer); ok { if keyID, ok := tokenStore.Lookup(bearer); ok {
recordAccess(r, keyID) recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID))) next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return return
} }
} }
if keyring != nil { if keyring != nil {
if keyID, ok := keyring.Lookup(bearer); ok { if keyID, ok := keyring.Lookup(bearer); ok {
recordAccess(r, keyID) recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID))) next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return return
} }
} }
@@ -103,7 +108,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
return return
} }
recordAccess(r, keyID) recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID))) next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return return
} }
@@ -117,7 +122,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
return return
} }
recordAccess(r, keyID) recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID))) next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return return
} }
} }
+44
View File
@@ -0,0 +1,44 @@
package auth
import (
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/tenancy"
)
func TestMiddlewareAddsTenantKeyToContext(t *testing.T) {
keyring, err := NewKeyring([]config.APIKey{{ID: "user-a", Value: "secret-a"}})
if err != nil {
t.Fatalf("NewKeyring error = %v", err)
}
var gotKeyID, gotTenant string
handler := Middleware(config.AuthConfig{}, keyring, nil, nil, nil, slog.Default())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ok bool
gotKeyID, ok = KeyIDFromContext(r.Context())
if !ok {
t.Fatal("KeyIDFromContext ok = false")
}
gotTenant, ok = tenancy.KeyFromContext(r.Context())
if !ok {
t.Fatal("tenancy.KeyFromContext ok = false")
}
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Header.Set("x-brain-key", "secret-a")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusNoContent)
}
if gotKeyID != "user-a" || gotTenant != "user-a" {
t.Fatalf("keyID=%q tenant=%q, want user-a/user-a", gotKeyID, gotTenant)
}
}
@@ -3,21 +3,21 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicAgentGuardrails struct { type ModelPublicAgentGuardrails struct {
bun.BaseModel `bun:"table:public.agent_guardrails,alias:agent_guardrails"` bun.BaseModel `bun:"table:public.agent_guardrails,alias:agent_guardrails"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"` Content sql_types.SqlString `bun:"content,type:text,notnull," json:"content"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"` Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"` GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"` Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
Severity resolvespec_common.SqlString `bun:"severity,type:text,default:'medium',notnull," json:"severity"` Severity sql_types.SqlString `bun:"severity,type:text,default:'medium',notnull," json:"severity"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text,default:'{}',notnull," json:"tags"` Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"` UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelGuardrailIDPublicAgentPersonaGuardrails []*ModelPublicAgentPersonaGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicagentpersonaguardrails,omitempty"` // Has many ModelPublicAgentPersonaGuardrails RelGuardrailIDPublicAgentPersonaGuardrails []*ModelPublicAgentPersonaGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicagentpersonaguardrails,omitempty"` // Has many ModelPublicAgentPersonaGuardrails
RelGuardrailIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails RelGuardrailIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails
RelGuardrailIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails RelGuardrailIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
@@ -45,7 +45,7 @@ func (m ModelPublicAgentGuardrails) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicAgentGuardrails) GetIDStr() string { func (m ModelPublicAgentGuardrails) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,24 +3,24 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicAgentParts struct { type ModelPublicAgentParts struct {
bun.BaseModel `bun:"table:public.agent_parts,alias:agent_parts"` bun.BaseModel `bun:"table:public.agent_parts,alias:agent_parts"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
Content resolvespec_common.SqlString `bun:"content,type:text,default:'',notnull," json:"content"` Content sql_types.SqlString `bun:"content,type:text,default:'',notnull," json:"content"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"` Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"` GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"` Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
PartType resolvespec_common.SqlString `bun:"part_type,type:text,notnull," json:"part_type"` PartType sql_types.SqlString `bun:"part_type,type:text,notnull," json:"part_type"`
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"` Summary sql_types.SqlString `bun:"summary,type:text,notnull," json:"summary"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text,default:'{}',notnull," json:"tags"` Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"` UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelPartIDPublicAgentPersonaParts []*ModelPublicAgentPersonaParts `bun:"rel:has-many,join:id=part_id" json:"relpartidpublicagentpersonaparts,omitempty"` // Has many ModelPublicAgentPersonaParts RelPartIDPublicAgentPersonaParts []*ModelPublicAgentPersonaParts `bun:"rel:has-many,join:id=part_id" json:"relpartidpublicagentpersonaparts,omitempty"` // Has many ModelPublicAgentPersonaParts
RelPartIDPublicArcStageParts []*ModelPublicArcStageParts `bun:"rel:has-many,join:id=part_id" json:"relpartidpublicarcstageparts,omitempty"` // Has many ModelPublicArcStageParts RelPartIDPublicArcStageParts []*ModelPublicArcStageParts `bun:"rel:has-many,join:id=part_id" json:"relpartidpublicarcstageparts,omitempty"` // Has many ModelPublicArcStageParts
} }
// TableName returns the table name for ModelPublicAgentParts // TableName returns the table name for ModelPublicAgentParts
@@ -45,7 +45,7 @@ func (m ModelPublicAgentParts) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicAgentParts) GetIDStr() string { func (m ModelPublicAgentParts) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,13 +3,13 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicAgentPersonaGuardrails struct { type ModelPublicAgentPersonaGuardrails struct {
bun.BaseModel `bun:"table:public.agent_persona_guardrails,alias:agent_persona_guardrails"` bun.BaseModel `bun:"table:public.agent_persona_guardrails,alias:agent_persona_guardrails"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull," json:"guardrail_id"` GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull," json:"guardrail_id"`
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"` PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
@@ -38,7 +38,7 @@ func (m ModelPublicAgentPersonaGuardrails) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicAgentPersonaGuardrails) GetIDStr() string { func (m ModelPublicAgentPersonaGuardrails) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,19 +3,19 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicAgentPersonaParts struct { type ModelPublicAgentPersonaParts struct {
bun.BaseModel `bun:"table:public.agent_persona_parts,alias:agent_persona_parts"` bun.BaseModel `bun:"table:public.agent_persona_parts,alias:agent_persona_parts"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
PartID int64 `bun:"part_id,type:bigint,notnull," json:"part_id"` PartID int64 `bun:"part_id,type:bigint,notnull," json:"part_id"`
PartOrder int32 `bun:"part_order,type:int,default:0,notnull," json:"part_order"` PartOrder int32 `bun:"part_order,type:int,default:0,notnull," json:"part_order"`
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"` PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
Priority int32 `bun:"priority,type:int,default:0,notnull," json:"priority"` Priority int32 `bun:"priority,type:int,default:0,notnull," json:"priority"`
RelPartID *ModelPublicAgentParts `bun:"rel:has-one,join:part_id=id" json:"relpartid,omitempty"` // Has one ModelPublicAgentParts RelPartID *ModelPublicAgentParts `bun:"rel:has-one,join:part_id=id" json:"relpartid,omitempty"` // Has one ModelPublicAgentParts
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
} }
// TableName returns the table name for ModelPublicAgentPersonaParts // TableName returns the table name for ModelPublicAgentPersonaParts
@@ -40,7 +40,7 @@ func (m ModelPublicAgentPersonaParts) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicAgentPersonaParts) GetIDStr() string { func (m ModelPublicAgentPersonaParts) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,17 +3,18 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicAgentPersonaSkills struct { type ModelPublicAgentPersonaSkills struct {
bun.BaseModel `bun:"table:public.agent_persona_skills,alias:agent_persona_skills"` bun.BaseModel `bun:"table:public.agent_persona_skills,alias:agent_persona_skills"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"` Override bool `bun:"override,type:boolean,default:false,notnull," json:"override"`
SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"` PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"`
RelSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:skill_id=id" json:"relskillid,omitempty"` // Has one ModelPublicAgentSkills RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
RelSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:skill_id=id" json:"relskillid,omitempty"` // Has one ModelPublicAgentSkills
} }
// TableName returns the table name for ModelPublicAgentPersonaSkills // TableName returns the table name for ModelPublicAgentPersonaSkills
@@ -38,7 +39,7 @@ func (m ModelPublicAgentPersonaSkills) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicAgentPersonaSkills) GetIDStr() string { func (m ModelPublicAgentPersonaSkills) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,17 +3,17 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicAgentPersonaTraits struct { type ModelPublicAgentPersonaTraits struct {
bun.BaseModel `bun:"table:public.agent_persona_traits,alias:agent_persona_traits"` bun.BaseModel `bun:"table:public.agent_persona_traits,alias:agent_persona_traits"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"` PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
TraitID int64 `bun:"trait_id,type:bigint,notnull," json:"trait_id"` TraitID int64 `bun:"trait_id,type:bigint,notnull," json:"trait_id"`
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
RelTraitID *ModelPublicAgentTraits `bun:"rel:has-one,join:trait_id=id" json:"reltraitid,omitempty"` // Has one ModelPublicAgentTraits RelTraitID *ModelPublicAgentTraits `bun:"rel:has-one,join:trait_id=id" json:"reltraitid,omitempty"` // Has one ModelPublicAgentTraits
} }
// TableName returns the table name for ModelPublicAgentPersonaTraits // TableName returns the table name for ModelPublicAgentPersonaTraits
@@ -38,7 +38,7 @@ func (m ModelPublicAgentPersonaTraits) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicAgentPersonaTraits) GetIDStr() string { func (m ModelPublicAgentPersonaTraits) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,26 +3,27 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicAgentPersonas struct { type ModelPublicAgentPersonas struct {
bun.BaseModel `bun:"table:public.agent_personas,alias:agent_personas"` bun.BaseModel `bun:"table:public.agent_personas,alias:agent_personas"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CompiledAt resolvespec_common.SqlTimeStamp `bun:"compiled_at,type:timestamptz,nullzero," json:"compiled_at"` CompiledAt sql_types.SqlTimeStamp `bun:"compiled_at,type:timestamptz,nullzero," json:"compiled_at"`
CompiledDetail resolvespec_common.SqlString `bun:"compiled_detail,type:text,default:'',notnull," json:"compiled_detail"` CompiledDetail sql_types.SqlString `bun:"compiled_detail,type:text,default:'',notnull," json:"compiled_detail"`
CompiledSummary resolvespec_common.SqlString `bun:"compiled_summary,type:text,default:'',notnull," json:"compiled_summary"` CompiledSummary sql_types.SqlString `bun:"compiled_summary,type:text,default:'',notnull," json:"compiled_summary"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"` Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
Detail resolvespec_common.SqlString `bun:"detail,type:text,default:'',notnull," json:"detail"` Detail sql_types.SqlString `bun:"detail,type:text,default:'',notnull," json:"detail"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"` GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"` Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"` Summary sql_types.SqlString `bun:"summary,type:text,notnull," json:"summary"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text,default:'{}',notnull," json:"tags"` Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"` UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelPersonaIDPublicAgentPersonaParts []*ModelPublicAgentPersonaParts `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaparts,omitempty"` // Has many ModelPublicAgentPersonaParts RelPersonaIDPublicAgentPersonaParts []*ModelPublicAgentPersonaParts `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaparts,omitempty"` // Has many ModelPublicAgentPersonaParts
RelPersonaIDPublicAgentPersonaSkills []*ModelPublicAgentPersonaSkills `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaskills,omitempty"` // Has many ModelPublicAgentPersonaSkills RelPersonaIDPublicAgentPersonaSkills []*ModelPublicAgentPersonaSkills `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaskills,omitempty"` // Has many ModelPublicAgentPersonaSkills
RelPersonaIDPublicProjectPersonas []*ModelPublicProjectPersonas `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicprojectpersonas,omitempty"` // Has many ModelPublicProjectPersonas
RelPersonaIDPublicAgentPersonaGuardrails []*ModelPublicAgentPersonaGuardrails `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaguardrails,omitempty"` // Has many ModelPublicAgentPersonaGuardrails RelPersonaIDPublicAgentPersonaGuardrails []*ModelPublicAgentPersonaGuardrails `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaguardrails,omitempty"` // Has many ModelPublicAgentPersonaGuardrails
RelPersonaIDPublicAgentPersonaTraits []*ModelPublicAgentPersonaTraits `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonatraits,omitempty"` // Has many ModelPublicAgentPersonaTraits RelPersonaIDPublicAgentPersonaTraits []*ModelPublicAgentPersonaTraits `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonatraits,omitempty"` // Has many ModelPublicAgentPersonaTraits
RelPersonaIDPublicPersonaArcs []*ModelPublicPersonaArc `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicpersonaarcs,omitempty"` // Has many ModelPublicPersonaArc RelPersonaIDPublicPersonaArcs []*ModelPublicPersonaArc `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicpersonaarcs,omitempty"` // Has many ModelPublicPersonaArc
@@ -50,7 +51,7 @@ func (m ModelPublicAgentPersonas) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicAgentPersonas) GetIDStr() string { func (m ModelPublicAgentPersonas) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,28 +3,28 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicAgentSkills struct { type ModelPublicAgentSkills struct {
bun.BaseModel `bun:"table:public.agent_skills,alias:agent_skills"` bun.BaseModel `bun:"table:public.agent_skills,alias:agent_skills"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"` Content sql_types.SqlString `bun:"content,type:text,notnull," json:"content"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"` Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
DomainTags resolvespec_common.SqlStringArray `bun:"domain_tags,type:text,default:'{}',notnull," json:"domain_tags"` DomainTags sql_types.SqlStringArray `bun:"domain_tags,type:text[],default:'{}',notnull," json:"domain_tags"`
FrameworkTags resolvespec_common.SqlStringArray `bun:"framework_tags,type:text,default:'{}',notnull," json:"framework_tags"` FrameworkTags sql_types.SqlStringArray `bun:"framework_tags,type:text[],default:'{}',notnull," json:"framework_tags"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"` GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
LanguageTags resolvespec_common.SqlStringArray `bun:"language_tags,type:text,default:'{}',notnull," json:"language_tags"` LanguageTags sql_types.SqlStringArray `bun:"language_tags,type:text[],default:'{}',notnull," json:"language_tags"`
LibraryTags resolvespec_common.SqlStringArray `bun:"library_tags,type:text,default:'{}',notnull," json:"library_tags"` LibraryTags sql_types.SqlStringArray `bun:"library_tags,type:text[],default:'{}',notnull," json:"library_tags"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"` Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text,default:'{}',notnull," json:"tags"` Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"` UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelSkillIDPublicAgentPersonaSkills []*ModelPublicAgentPersonaSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicagentpersonaskills,omitempty"` // Has many ModelPublicAgentPersonaSkills RelSkillIDPublicAgentPersonaSkills []*ModelPublicAgentPersonaSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicagentpersonaskills,omitempty"` // Has many ModelPublicAgentPersonaSkills
RelRelatedSkillIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_skill_id" json:"relrelatedskillidpubliclearnings,omitempty"` // Has many ModelPublicLearnings RelRelatedSkillIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_skill_id" json:"relrelatedskillidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
RelSkillIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills RelSkillIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills
RelSkillIDPublicProjectSkills []*ModelPublicProjectSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicprojectskills,omitempty"` // Has many ModelPublicProjectSkills RelSkillIDPublicProjectSkills []*ModelPublicProjectSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicprojectskills,omitempty"` // Has many ModelPublicProjectSkills
} }
// TableName returns the table name for ModelPublicAgentSkills // TableName returns the table name for ModelPublicAgentSkills
@@ -49,7 +49,7 @@ func (m ModelPublicAgentSkills) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicAgentSkills) GetIDStr() string { func (m ModelPublicAgentSkills) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,22 +3,22 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicAgentTraits struct { type ModelPublicAgentTraits struct {
bun.BaseModel `bun:"table:public.agent_traits,alias:agent_traits"` bun.BaseModel `bun:"table:public.agent_traits,alias:agent_traits"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"` Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"` GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Instruction resolvespec_common.SqlString `bun:"instruction,type:text,default:'',notnull," json:"instruction"` Instruction sql_types.SqlString `bun:"instruction,type:text,default:'',notnull," json:"instruction"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"` Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text,default:'{}',notnull," json:"tags"` Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
TraitType resolvespec_common.SqlString `bun:"trait_type,type:text,notnull," json:"trait_type"` TraitType sql_types.SqlString `bun:"trait_type,type:text,notnull," json:"trait_type"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"` UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelTraitIDPublicAgentPersonaTraits []*ModelPublicAgentPersonaTraits `bun:"rel:has-many,join:id=trait_id" json:"reltraitidpublicagentpersonatraits,omitempty"` // Has many ModelPublicAgentPersonaTraits RelTraitIDPublicAgentPersonaTraits []*ModelPublicAgentPersonaTraits `bun:"rel:has-many,join:id=trait_id" json:"reltraitidpublicagentpersonatraits,omitempty"` // Has many ModelPublicAgentPersonaTraits
} }
// TableName returns the table name for ModelPublicAgentTraits // TableName returns the table name for ModelPublicAgentTraits
@@ -43,7 +43,7 @@ func (m ModelPublicAgentTraits) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicAgentTraits) GetIDStr() string { func (m ModelPublicAgentTraits) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,17 +3,17 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicArcStageParts struct { type ModelPublicArcStageParts struct {
bun.BaseModel `bun:"table:public.arc_stage_parts,alias:arc_stage_parts"` bun.BaseModel `bun:"table:public.arc_stage_parts,alias:arc_stage_parts"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
PartID int64 `bun:"part_id,type:bigint,notnull," json:"part_id"` PartID int64 `bun:"part_id,type:bigint,notnull," json:"part_id"`
StageID int64 `bun:"stage_id,type:bigint,notnull," json:"stage_id"` StageID int64 `bun:"stage_id,type:bigint,notnull," json:"stage_id"`
RelPartID *ModelPublicAgentParts `bun:"rel:has-one,join:part_id=id" json:"relpartid,omitempty"` // Has one ModelPublicAgentParts RelPartID *ModelPublicAgentParts `bun:"rel:has-one,join:part_id=id" json:"relpartid,omitempty"` // Has one ModelPublicAgentParts
RelStageID *ModelPublicArcStages `bun:"rel:has-one,join:stage_id=id" json:"relstageid,omitempty"` // Has one ModelPublicArcStages RelStageID *ModelPublicArcStages `bun:"rel:has-one,join:stage_id=id" json:"relstageid,omitempty"` // Has one ModelPublicArcStages
} }
// TableName returns the table name for ModelPublicArcStageParts // TableName returns the table name for ModelPublicArcStageParts
@@ -38,7 +38,7 @@ func (m ModelPublicArcStageParts) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicArcStageParts) GetIDStr() string { func (m ModelPublicArcStageParts) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,22 +3,22 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicArcStages struct { type ModelPublicArcStages struct {
bun.BaseModel `bun:"table:public.arc_stages,alias:arc_stages"` bun.BaseModel `bun:"table:public.arc_stages,alias:arc_stages"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ArcID int64 `bun:"arc_id,type:bigint,notnull," json:"arc_id"` ArcID int64 `bun:"arc_id,type:bigint,notnull," json:"arc_id"`
Condition resolvespec_common.SqlString `bun:"condition,type:text,default:'',notnull," json:"condition"` Condition sql_types.SqlString `bun:"condition,type:text,default:'',notnull," json:"condition"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"` Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"` Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
StageOrder int32 `bun:"stage_order,type:int,default:0,notnull," json:"stage_order"` StageOrder int32 `bun:"stage_order,type:int,default:0,notnull," json:"stage_order"`
RelArcID *ModelPublicCharacterArcs `bun:"rel:has-one,join:arc_id=id" json:"relarcid,omitempty"` // Has one ModelPublicCharacterArcs RelArcID *ModelPublicCharacterArcs `bun:"rel:has-one,join:arc_id=id" json:"relarcid,omitempty"` // Has one ModelPublicCharacterArcs
RelStageIDPublicArcStageParts []*ModelPublicArcStageParts `bun:"rel:has-many,join:id=stage_id" json:"relstageidpublicarcstageparts,omitempty"` // Has many ModelPublicArcStageParts RelStageIDPublicArcStageParts []*ModelPublicArcStageParts `bun:"rel:has-many,join:id=stage_id" json:"relstageidpublicarcstageparts,omitempty"` // Has many ModelPublicArcStageParts
RelCurrentStageIDPublicPersonaArcs []*ModelPublicPersonaArc `bun:"rel:has-many,join:id=current_stage_id" json:"relcurrentstageidpublicpersonaarcs,omitempty"` // Has many ModelPublicPersonaArc RelCurrentStageIDPublicPersonaArcs []*ModelPublicPersonaArc `bun:"rel:has-many,join:id=current_stage_id" json:"relcurrentstageidpublicpersonaarcs,omitempty"` // Has many ModelPublicPersonaArc
} }
// TableName returns the table name for ModelPublicArcStages // TableName returns the table name for ModelPublicArcStages
@@ -43,7 +43,7 @@ func (m ModelPublicArcStages) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicArcStages) GetIDStr() string { func (m ModelPublicArcStages) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,20 +3,20 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicCharacterArcs struct { type ModelPublicCharacterArcs struct {
bun.BaseModel `bun:"table:public.character_arcs,alias:character_arcs"` bun.BaseModel `bun:"table:public.character_arcs,alias:character_arcs"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"` Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"` Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
Summary resolvespec_common.SqlString `bun:"summary,type:text,default:'',notnull," json:"summary"` Summary sql_types.SqlString `bun:"summary,type:text,default:'',notnull," json:"summary"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"` UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelArcIDPublicArcStages []*ModelPublicArcStages `bun:"rel:has-many,join:id=arc_id" json:"relarcidpublicarcstages,omitempty"` // Has many ModelPublicArcStages RelArcIDPublicArcStages []*ModelPublicArcStages `bun:"rel:has-many,join:id=arc_id" json:"relarcidpublicarcstages,omitempty"` // Has many ModelPublicArcStages
RelArcIDPublicPersonaArcs []*ModelPublicPersonaArc `bun:"rel:has-many,join:id=arc_id" json:"relarcidpublicpersonaarcs,omitempty"` // Has many ModelPublicPersonaArc RelArcIDPublicPersonaArcs []*ModelPublicPersonaArc `bun:"rel:has-many,join:id=arc_id" json:"relarcidpublicpersonaarcs,omitempty"` // Has many ModelPublicPersonaArc
} }
// TableName returns the table name for ModelPublicCharacterArcs // TableName returns the table name for ModelPublicCharacterArcs
@@ -41,7 +41,7 @@ func (m ModelPublicCharacterArcs) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicCharacterArcs) GetIDStr() string { func (m ModelPublicCharacterArcs) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,25 +3,26 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicChatHistories struct { type ModelPublicChatHistories struct {
bun.BaseModel `bun:"table:public.chat_histories,alias:chat_histories"` bun.BaseModel `bun:"table:public.chat_histories,alias:chat_histories"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
AgentID resolvespec_common.SqlString `bun:"agent_id,type:text,nullzero," json:"agent_id"` AgentID sql_types.SqlString `bun:"agent_id,type:text,nullzero," json:"agent_id"`
Channel resolvespec_common.SqlString `bun:"channel,type:text,nullzero," json:"channel"` Channel sql_types.SqlString `bun:"channel,type:text,nullzero," json:"channel"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"` GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Messages resolvespec_common.SqlJSONB `bun:"messages,type:jsonb,default:'',notnull," json:"messages"` Messages sql_types.SqlJSONB `bun:"messages,type:jsonb,default:'[]',notnull," json:"messages"`
Metadata resolvespec_common.SqlJSONB `bun:"metadata,type:jsonb,default:'{}',notnull," json:"metadata"` Metadata sql_types.SqlJSONB `bun:"metadata,type:jsonb,default:'{}',notnull," json:"metadata"`
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"` ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
SessionID resolvespec_common.SqlString `bun:"session_id,type:text,notnull," json:"session_id"` SessionID sql_types.SqlString `bun:"session_id,type:text,notnull," json:"session_id"`
Summary resolvespec_common.SqlString `bun:"summary,type:text,nullzero," json:"summary"` Summary sql_types.SqlString `bun:"summary,type:text,nullzero," json:"summary"`
Title resolvespec_common.SqlString `bun:"title,type:text,nullzero," json:"title"` TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"` Title sql_types.SqlString `bun:"title,type:text,nullzero," json:"title"`
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
} }
// TableName returns the table name for ModelPublicChatHistories // TableName returns the table name for ModelPublicChatHistories
@@ -46,7 +47,7 @@ func (m ModelPublicChatHistories) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicChatHistories) GetIDStr() string { func (m ModelPublicChatHistories) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,21 +3,21 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicEmbeddings struct { type ModelPublicEmbeddings struct {
bun.BaseModel `bun:"table:public.embeddings,alias:embeddings"` bun.BaseModel `bun:"table:public.embeddings,alias:embeddings"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
Dim int32 `bun:"dim,type:int,notnull," json:"dim"` Dim int32 `bun:"dim,type:int,notnull," json:"dim"`
Embedding resolvespec_common.SqlVector `bun:"embedding,type:vector,notnull," json:"embedding"` Embedding sql_types.SqlVector `bun:"embedding,type:vector,notnull," json:"embedding"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"` GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Model resolvespec_common.SqlString `bun:"model,type:text,notnull,unique:uidx_embeddings_thought_id_model," json:"model"` Model sql_types.SqlString `bun:"model,type:text,notnull,unique:uidx_embeddings_thought_id_model," json:"model"`
ThoughtID int64 `bun:"thought_id,type:bigint,notnull,unique:uidx_embeddings_thought_id_model," json:"thought_id"` ThoughtID int64 `bun:"thought_id,type:bigint,notnull,unique:uidx_embeddings_thought_id_model," json:"thought_id"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"` UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"`
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
} }
// TableName returns the table name for ModelPublicEmbeddings // TableName returns the table name for ModelPublicEmbeddings
@@ -42,7 +42,7 @@ func (m ModelPublicEmbeddings) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicEmbeddings) GetIDStr() string { func (m ModelPublicEmbeddings) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,39 +3,41 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicLearnings struct { type ModelPublicLearnings struct {
bun.BaseModel `bun:"table:public.learnings,alias:learnings"` bun.BaseModel `bun:"table:public.learnings,alias:learnings"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ActionRequired bool `bun:"action_required,type:boolean,default:false,notnull," json:"action_required"` ActionRequired bool `bun:"action_required,type:boolean,default:false,notnull," json:"action_required"`
Area resolvespec_common.SqlString `bun:"area,type:text,default:'other',notnull," json:"area"` Area sql_types.SqlString `bun:"area,type:text,default:'other',notnull," json:"area"`
Category resolvespec_common.SqlString `bun:"category,type:text,default:'insight',notnull," json:"category"` Category sql_types.SqlString `bun:"category,type:text,default:'insight',notnull," json:"category"`
Confidence resolvespec_common.SqlString `bun:"confidence,type:text,default:'hypothesis',notnull," json:"confidence"` Confidence sql_types.SqlString `bun:"confidence,type:text,default:'hypothesis',notnull," json:"confidence"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Details resolvespec_common.SqlString `bun:"details,type:text,default:'',notnull," json:"details"` Details sql_types.SqlString `bun:"details,type:text,default:'',notnull," json:"details"`
DuplicateOfLearningID resolvespec_common.SqlInt64 `bun:"duplicate_of_learning_id,type:bigint,nullzero," json:"duplicate_of_learning_id"` DuplicateOfLearningID sql_types.SqlInt64 `bun:"duplicate_of_learning_id,type:bigint,nullzero," json:"duplicate_of_learning_id"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"` GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"` Priority sql_types.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"`
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"` ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
RelatedSkillID resolvespec_common.SqlInt64 `bun:"related_skill_id,type:bigint,nullzero," json:"related_skill_id"` RelatedSkillID sql_types.SqlInt64 `bun:"related_skill_id,type:bigint,nullzero," json:"related_skill_id"`
RelatedThoughtID resolvespec_common.SqlInt64 `bun:"related_thought_id,type:bigint,nullzero," json:"related_thought_id"` RelatedThoughtID sql_types.SqlInt64 `bun:"related_thought_id,type:bigint,nullzero," json:"related_thought_id"`
ReviewedAt resolvespec_common.SqlTimeStamp `bun:"reviewed_at,type:timestamptz,nullzero," json:"reviewed_at"` ReviewedAt sql_types.SqlTimeStamp `bun:"reviewed_at,type:timestamptz,nullzero," json:"reviewed_at"`
ReviewedBy resolvespec_common.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"` ReviewedBy sql_types.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
SourceRef resolvespec_common.SqlString `bun:"source_ref,type:text,nullzero," json:"source_ref"` SourceRef sql_types.SqlString `bun:"source_ref,type:text,nullzero," json:"source_ref"`
SourceType resolvespec_common.SqlString `bun:"source_type,type:text,nullzero," json:"source_type"` SourceType sql_types.SqlString `bun:"source_type,type:text,nullzero," json:"source_type"`
Status resolvespec_common.SqlString `bun:"status,type:text,default:'pending',notnull," json:"status"` Status sql_types.SqlString `bun:"status,type:text,default:'pending',notnull," json:"status"`
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"` Summary sql_types.SqlString `bun:"summary,type:text,notnull," json:"summary"`
SupersedesLearningID resolvespec_common.SqlInt64 `bun:"supersedes_learning_id,type:bigint,nullzero," json:"supersedes_learning_id"` SupersedesLearningID sql_types.SqlInt64 `bun:"supersedes_learning_id,type:bigint,nullzero," json:"supersedes_learning_id"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text,default:'{}',notnull," json:"tags"` Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"` TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
RelDuplicateOfLearningID *ModelPublicLearnings `bun:"rel:has-one,join:duplicate_of_learning_id=id" json:"relduplicateoflearningid,omitempty"` // Has one ModelPublicLearnings UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects RelDuplicateOfLearningID *ModelPublicLearnings `bun:"rel:has-one,join:duplicate_of_learning_id=id" json:"relduplicateoflearningid,omitempty"` // Has one ModelPublicLearnings
RelRelatedSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:related_skill_id=id" json:"relrelatedskillid,omitempty"` // Has one ModelPublicAgentSkills RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
RelRelatedThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:related_thought_id=id" json:"relrelatedthoughtid,omitempty"` // Has one ModelPublicThoughts RelRelatedSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:related_skill_id=id" json:"relrelatedskillid,omitempty"` // Has one ModelPublicAgentSkills
RelSupersedesLearningID *ModelPublicLearnings `bun:"rel:has-one,join:supersedes_learning_id=id" json:"relsupersedeslearningid,omitempty"` // Has one ModelPublicLearnings RelRelatedThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:related_thought_id=id" json:"relrelatedthoughtid,omitempty"` // Has one ModelPublicThoughts
RelSupersedesLearningID *ModelPublicLearnings `bun:"rel:has-one,join:supersedes_learning_id=id" json:"relsupersedeslearningid,omitempty"` // Has one ModelPublicLearnings
RelLearningIDPublicThoughtLearningLinks []*ModelPublicThoughtLearningLinks `bun:"rel:has-many,join:id=learning_id" json:"rellearningidpublicthoughtlearninglinks,omitempty"` // Has many ModelPublicThoughtLearningLinks
} }
// TableName returns the table name for ModelPublicLearnings // TableName returns the table name for ModelPublicLearnings
@@ -60,7 +62,7 @@ func (m ModelPublicLearnings) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicLearnings) GetIDStr() string { func (m ModelPublicLearnings) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,17 +3,17 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicOauthClients struct { type ModelPublicOauthClients struct {
bun.BaseModel `bun:"table:public.oauth_clients,alias:oauth_clients"` bun.BaseModel `bun:"table:public.oauth_clients,alias:oauth_clients"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ClientID resolvespec_common.SqlString `bun:"client_id,type:text,notnull," json:"client_id"` ClientID sql_types.SqlString `bun:"client_id,type:text,notnull," json:"client_id"`
ClientName resolvespec_common.SqlString `bun:"client_name,type:text,default:'',notnull," json:"client_name"` ClientName sql_types.SqlString `bun:"client_name,type:text,default:'',notnull," json:"client_name"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
RedirectUris resolvespec_common.SqlStringArray `bun:"redirect_uris,type:text,default:'{}',notnull," json:"redirect_uris"` RedirectUris sql_types.SqlStringArray `bun:"redirect_uris,type:text[],default:'{}',notnull," json:"redirect_uris"`
} }
// TableName returns the table name for ModelPublicOauthClients // TableName returns the table name for ModelPublicOauthClients
@@ -38,7 +38,7 @@ func (m ModelPublicOauthClients) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicOauthClients) GetIDStr() string { func (m ModelPublicOauthClients) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,20 +3,20 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicPersonaArc struct { type ModelPublicPersonaArc struct {
bun.BaseModel `bun:"table:public.persona_arc,alias:persona_arc"` bun.BaseModel `bun:"table:public.persona_arc,alias:persona_arc"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
PersonaID int64 `bun:"persona_id,type:bigint,pk," json:"persona_id"` PersonaID int64 `bun:"persona_id,type:bigint,pk," json:"persona_id"`
ArcID int64 `bun:"arc_id,type:bigint,notnull," json:"arc_id"` ArcID int64 `bun:"arc_id,type:bigint,notnull," json:"arc_id"`
CurrentStageID int64 `bun:"current_stage_id,type:bigint,notnull," json:"current_stage_id"` CurrentStageID int64 `bun:"current_stage_id,type:bigint,notnull," json:"current_stage_id"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"` UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelArcID *ModelPublicCharacterArcs `bun:"rel:has-one,join:arc_id=id" json:"relarcid,omitempty"` // Has one ModelPublicCharacterArcs RelArcID *ModelPublicCharacterArcs `bun:"rel:has-one,join:arc_id=id" json:"relarcid,omitempty"` // Has one ModelPublicCharacterArcs
RelCurrentStageID *ModelPublicArcStages `bun:"rel:has-one,join:current_stage_id=id" json:"relcurrentstageid,omitempty"` // Has one ModelPublicArcStages RelCurrentStageID *ModelPublicArcStages `bun:"rel:has-one,join:current_stage_id=id" json:"relcurrentstageid,omitempty"` // Has one ModelPublicArcStages
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
} }
// TableName returns the table name for ModelPublicPersonaArc // TableName returns the table name for ModelPublicPersonaArc
@@ -41,7 +41,7 @@ func (m ModelPublicPersonaArc) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicPersonaArc) GetIDStr() string { func (m ModelPublicPersonaArc) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,18 +3,18 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicPlanDependencies struct { type ModelPublicPlanDependencies struct {
bun.BaseModel `bun:"table:public.plan_dependencies,alias:plan_dependencies"` bun.BaseModel `bun:"table:public.plan_dependencies,alias:plan_dependencies"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
DependsOnPlanID int64 `bun:"depends_on_plan_id,type:bigint,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"depends_on_plan_id"` DependsOnPlanID int64 `bun:"depends_on_plan_id,type:bigint,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"depends_on_plan_id"`
PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"plan_id"` PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"plan_id"`
RelDependsOnPlanID *ModelPublicPlans `bun:"rel:has-one,join:depends_on_plan_id=id" json:"reldependsonplanid,omitempty"` // Has one ModelPublicPlans RelDependsOnPlanID *ModelPublicPlans `bun:"rel:has-one,join:depends_on_plan_id=id" json:"reldependsonplanid,omitempty"` // Has one ModelPublicPlans
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
} }
// TableName returns the table name for ModelPublicPlanDependencies // TableName returns the table name for ModelPublicPlanDependencies
@@ -39,7 +39,7 @@ func (m ModelPublicPlanDependencies) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicPlanDependencies) GetIDStr() string { func (m ModelPublicPlanDependencies) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,18 +3,18 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicPlanGuardrails struct { type ModelPublicPlanGuardrails struct {
bun.BaseModel `bun:"table:public.plan_guardrails,alias:plan_guardrails"` bun.BaseModel `bun:"table:public.plan_guardrails,alias:plan_guardrails"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"guardrail_id"` GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"guardrail_id"`
PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"plan_id"` PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"plan_id"`
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
} }
// TableName returns the table name for ModelPublicPlanGuardrails // TableName returns the table name for ModelPublicPlanGuardrails
@@ -39,7 +39,7 @@ func (m ModelPublicPlanGuardrails) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicPlanGuardrails) GetIDStr() string { func (m ModelPublicPlanGuardrails) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,18 +3,18 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicPlanRelatedPlans struct { type ModelPublicPlanRelatedPlans struct {
bun.BaseModel `bun:"table:public.plan_related_plans,alias:plan_related_plans"` bun.BaseModel `bun:"table:public.plan_related_plans,alias:plan_related_plans"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
PlanAID int64 `bun:"plan_a_id,type:bigint,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_a_id"` PlanAID int64 `bun:"plan_a_id,type:bigint,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_a_id"`
PlanBID int64 `bun:"plan_b_id,type:bigint,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_b_id"` PlanBID int64 `bun:"plan_b_id,type:bigint,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_b_id"`
RelPlanAID *ModelPublicPlans `bun:"rel:has-one,join:plan_a_id=id" json:"relplanaid,omitempty"` // Has one ModelPublicPlans RelPlanAID *ModelPublicPlans `bun:"rel:has-one,join:plan_a_id=id" json:"relplanaid,omitempty"` // Has one ModelPublicPlans
RelPlanBID *ModelPublicPlans `bun:"rel:has-one,join:plan_b_id=id" json:"relplanbid,omitempty"` // Has one ModelPublicPlans RelPlanBID *ModelPublicPlans `bun:"rel:has-one,join:plan_b_id=id" json:"relplanbid,omitempty"` // Has one ModelPublicPlans
} }
// TableName returns the table name for ModelPublicPlanRelatedPlans // TableName returns the table name for ModelPublicPlanRelatedPlans
@@ -39,7 +39,7 @@ func (m ModelPublicPlanRelatedPlans) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicPlanRelatedPlans) GetIDStr() string { func (m ModelPublicPlanRelatedPlans) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,18 +3,18 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicPlanSkills struct { type ModelPublicPlanSkills struct {
bun.BaseModel `bun:"table:public.plan_skills,alias:plan_skills"` bun.BaseModel `bun:"table:public.plan_skills,alias:plan_skills"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"plan_id"` PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"plan_id"`
SkillID int64 `bun:"skill_id,type:bigint,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"skill_id"` SkillID int64 `bun:"skill_id,type:bigint,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"skill_id"`
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
RelSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:skill_id=id" json:"relskillid,omitempty"` // Has one ModelPublicAgentSkills RelSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:skill_id=id" json:"relskillid,omitempty"` // Has one ModelPublicAgentSkills
} }
// TableName returns the table name for ModelPublicPlanSkills // TableName returns the table name for ModelPublicPlanSkills
@@ -39,7 +39,7 @@ func (m ModelPublicPlanSkills) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicPlanSkills) GetIDStr() string { func (m ModelPublicPlanSkills) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
+27 -26
View File
@@ -3,36 +3,37 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicPlans struct { type ModelPublicPlans struct {
bun.BaseModel `bun:"table:public.plans,alias:plans"` bun.BaseModel `bun:"table:public.plans,alias:plans"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CompletedAt resolvespec_common.SqlTimeStamp `bun:"completed_at,type:timestamptz,nullzero," json:"completed_at"` CompletedAt sql_types.SqlTimeStamp `bun:"completed_at,type:timestamptz,nullzero," json:"completed_at"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"` Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
DueDate resolvespec_common.SqlTimeStamp `bun:"due_date,type:timestamptz,nullzero," json:"due_date"` DueDate sql_types.SqlTimeStamp `bun:"due_date,type:timestamptz,nullzero," json:"due_date"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"` GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
LastReviewedAt resolvespec_common.SqlTimeStamp `bun:"last_reviewed_at,type:timestamptz,nullzero," json:"last_reviewed_at"` LastReviewedAt sql_types.SqlTimeStamp `bun:"last_reviewed_at,type:timestamptz,nullzero," json:"last_reviewed_at"`
Owner resolvespec_common.SqlString `bun:"owner,type:text,nullzero," json:"owner"` Owner sql_types.SqlString `bun:"owner,type:text,nullzero," json:"owner"`
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"` // low, medium, high, critical Priority sql_types.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"` // low, medium, high, critical
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"` ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
ReviewedBy resolvespec_common.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"` ReviewedBy sql_types.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
Status resolvespec_common.SqlString `bun:"status,type:text,default:'draft',notnull," json:"status"` // draft, active, blocked, completed, cancelled, superseded Status sql_types.SqlString `bun:"status,type:text,default:'draft',notnull," json:"status"` // draft, active, blocked, completed, cancelled, superseded
SupersedesPlanID resolvespec_common.SqlInt64 `bun:"supersedes_plan_id,type:bigint,nullzero," json:"supersedes_plan_id"` SupersedesPlanID sql_types.SqlInt64 `bun:"supersedes_plan_id,type:bigint,nullzero," json:"supersedes_plan_id"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text,default:'{}',notnull," json:"tags"` Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"` TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"` Title sql_types.SqlString `bun:"title,type:text,notnull," json:"title"`
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelSupersedesPlanID *ModelPublicPlans `bun:"rel:has-one,join:supersedes_plan_id=id" json:"relsupersedesplanid,omitempty"` // Has one ModelPublicPlans RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
RelDependsOnPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=depends_on_plan_id" json:"reldependsonplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies RelSupersedesPlanID *ModelPublicPlans `bun:"rel:has-one,join:supersedes_plan_id=id" json:"relsupersedesplanid,omitempty"` // Has one ModelPublicPlans
RelPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies RelDependsOnPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=depends_on_plan_id" json:"reldependsonplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
RelPlanAIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_a_id" json:"relplanaidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans RelPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
RelPlanBIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_b_id" json:"relplanbidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans RelPlanAIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_a_id" json:"relplanaidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans
RelPlanIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills RelPlanBIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_b_id" json:"relplanbidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans
RelPlanIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails RelPlanIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills
RelPlanIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails
} }
// TableName returns the table name for ModelPublicPlans // TableName returns the table name for ModelPublicPlans
@@ -57,7 +58,7 @@ func (m ModelPublicPlans) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicPlans) GetIDStr() string { func (m ModelPublicPlans) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,18 +3,18 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicProjectGuardrails struct { type ModelPublicProjectGuardrails struct {
bun.BaseModel `bun:"table:public.project_guardrails,alias:project_guardrails"` bun.BaseModel `bun:"table:public.project_guardrails,alias:project_guardrails"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull," json:"guardrail_id"` GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull," json:"guardrail_id"`
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"` ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
} }
// TableName returns the table name for ModelPublicProjectGuardrails // TableName returns the table name for ModelPublicProjectGuardrails
@@ -39,7 +39,7 @@ func (m ModelPublicProjectGuardrails) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicProjectGuardrails) GetIDStr() string { func (m ModelPublicProjectGuardrails) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -0,0 +1,64 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicProjectPersonas struct {
bun.BaseModel `bun:"table:public.project_personas,alias:project_personas"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
IsDefault bool `bun:"is_default,type:boolean,default:false,notnull," json:"is_default"`
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
}
// TableName returns the table name for ModelPublicProjectPersonas
func (m ModelPublicProjectPersonas) TableName() string {
return "public.project_personas"
}
// TableNameOnly returns the table name without schema for ModelPublicProjectPersonas
func (m ModelPublicProjectPersonas) TableNameOnly() string {
return "project_personas"
}
// SchemaName returns the schema name for ModelPublicProjectPersonas
func (m ModelPublicProjectPersonas) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicProjectPersonas) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicProjectPersonas) GetIDStr() string {
return m.ID.String()
}
// SetID sets the primary key value
func (m ModelPublicProjectPersonas) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicProjectPersonas) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicProjectPersonas) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicProjectPersonas) GetPrefix() string {
return "PPR"
}
@@ -3,18 +3,19 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicProjectSkills struct { type ModelPublicProjectSkills struct {
bun.BaseModel `bun:"table:public.project_skills,alias:project_skills"` bun.BaseModel `bun:"table:public.project_skills,alias:project_skills"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"` Override bool `bun:"override,type:boolean,default:false,notnull," json:"override"`
SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"` ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"`
RelSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:skill_id=id" json:"relskillid,omitempty"` // Has one ModelPublicAgentSkills RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
RelSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:skill_id=id" json:"relskillid,omitempty"` // Has one ModelPublicAgentSkills
} }
// TableName returns the table name for ModelPublicProjectSkills // TableName returns the table name for ModelPublicProjectSkills
@@ -39,7 +40,7 @@ func (m ModelPublicProjectSkills) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicProjectSkills) GetIDStr() string { func (m ModelPublicProjectSkills) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,19 +3,20 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicProjects struct { type ModelPublicProjects struct {
bun.BaseModel `bun:"table:public.projects,alias:projects"` bun.BaseModel `bun:"table:public.projects,alias:projects"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,nullzero," json:"description"` Description sql_types.SqlString `bun:"description,type:text,nullzero," json:"description"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"` GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
LastActiveAt resolvespec_common.SqlTimeStamp `bun:"last_active_at,type:timestamptz,default:now(),nullzero," json:"last_active_at"` LastActiveAt sql_types.SqlTimeStamp `bun:"last_active_at,type:timestamptz,default:now(),nullzero," json:"last_active_at"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"` Name sql_types.SqlString `bun:"name,type:text,notnull,unique:uidx_projects_tenant_key_name," json:"name"`
ThoughtCount resolvespec_common.SqlInt64 `bun:"thought_count,scanonly" json:"thought_count"` TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero,unique:uidx_projects_tenant_key_name," json:"tenant_key"`
RelProjectIDPublicProjectPersonas []*ModelPublicProjectPersonas `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicprojectpersonas,omitempty"` // Has many ModelPublicProjectPersonas
RelProjectIDPublicThoughts []*ModelPublicThoughts `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicthoughts,omitempty"` // Has many ModelPublicThoughts RelProjectIDPublicThoughts []*ModelPublicThoughts `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicthoughts,omitempty"` // Has many ModelPublicThoughts
RelProjectIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles RelProjectIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
RelProjectIDPublicChatHistories []*ModelPublicChatHistories `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicchathistories,omitempty"` // Has many ModelPublicChatHistories RelProjectIDPublicChatHistories []*ModelPublicChatHistories `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicchathistories,omitempty"` // Has many ModelPublicChatHistories
@@ -47,7 +48,7 @@ func (m ModelPublicProjects) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicProjects) GetIDStr() string { func (m ModelPublicProjects) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,27 +3,28 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicStoredFiles struct { type ModelPublicStoredFiles struct {
bun.BaseModel `bun:"table:public.stored_files,alias:stored_files"` bun.BaseModel `bun:"table:public.stored_files,alias:stored_files"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
Content []byte `bun:"content,type:bytea,notnull," json:"content"` Content []byte `bun:"content,type:bytea,notnull," json:"content"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Encoding resolvespec_common.SqlString `bun:"encoding,type:text,default:'base64',notnull," json:"encoding"` Encoding sql_types.SqlString `bun:"encoding,type:text,default:'base64',notnull," json:"encoding"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"` GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Kind resolvespec_common.SqlString `bun:"kind,type:text,default:'file',notnull," json:"kind"` Kind sql_types.SqlString `bun:"kind,type:text,default:'file',notnull," json:"kind"`
MediaType resolvespec_common.SqlString `bun:"media_type,type:text,notnull," json:"media_type"` MediaType sql_types.SqlString `bun:"media_type,type:text,notnull," json:"media_type"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"` Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"` ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
Sha256 resolvespec_common.SqlString `bun:"sha256,type:text,notnull," json:"sha256"` Sha256 sql_types.SqlString `bun:"sha256,type:text,notnull," json:"sha256"`
SizeBytes int64 `bun:"size_bytes,type:bigint,notnull," json:"size_bytes"` SizeBytes int64 `bun:"size_bytes,type:bigint,notnull," json:"size_bytes"`
ThoughtID resolvespec_common.SqlInt64 `bun:"thought_id,type:bigint,nullzero," json:"thought_id"` TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"` ThoughtID sql_types.SqlInt64 `bun:"thought_id,type:bigint,nullzero," json:"thought_id"`
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
} }
// TableName returns the table name for ModelPublicStoredFiles // TableName returns the table name for ModelPublicStoredFiles
@@ -48,7 +49,7 @@ func (m ModelPublicStoredFiles) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicStoredFiles) GetIDStr() string { func (m ModelPublicStoredFiles) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -0,0 +1,64 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicThoughtLearningLinks struct {
bun.BaseModel `bun:"table:public.thought_learning_links,alias:thought_learning_links"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
LearningID int64 `bun:"learning_id,type:bigint,notnull,unique:uidx_thought_learning_links_thought_id_learning_id," json:"learning_id"`
Relation sql_types.SqlString `bun:"relation,type:text,default:'source',notnull," json:"relation"`
ThoughtID int64 `bun:"thought_id,type:bigint,notnull,unique:uidx_thought_learning_links_thought_id_learning_id," json:"thought_id"`
RelLearningID *ModelPublicLearnings `bun:"rel:has-one,join:learning_id=id" json:"rellearningid,omitempty"` // Has one ModelPublicLearnings
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
}
// TableName returns the table name for ModelPublicThoughtLearningLinks
func (m ModelPublicThoughtLearningLinks) TableName() string {
return "public.thought_learning_links"
}
// TableNameOnly returns the table name without schema for ModelPublicThoughtLearningLinks
func (m ModelPublicThoughtLearningLinks) TableNameOnly() string {
return "thought_learning_links"
}
// SchemaName returns the schema name for ModelPublicThoughtLearningLinks
func (m ModelPublicThoughtLearningLinks) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicThoughtLearningLinks) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicThoughtLearningLinks) GetIDStr() string {
return m.ID.String()
}
// SetID sets the primary key value
func (m ModelPublicThoughtLearningLinks) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicThoughtLearningLinks) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicThoughtLearningLinks) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicThoughtLearningLinks) GetPrefix() string {
return "TLL"
}
@@ -3,19 +3,19 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicThoughtLinks struct { type ModelPublicThoughtLinks struct {
bun.BaseModel `bun:"table:public.thought_links,alias:thought_links"` bun.BaseModel `bun:"table:public.thought_links,alias:thought_links"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
FromID int64 `bun:"from_id,type:bigint,notnull," json:"from_id"` FromID int64 `bun:"from_id,type:bigint,notnull," json:"from_id"`
Relation resolvespec_common.SqlString `bun:"relation,type:text,notnull," json:"relation"` Relation sql_types.SqlString `bun:"relation,type:text,notnull," json:"relation"`
ToID int64 `bun:"to_id,type:bigint,notnull," json:"to_id"` ToID int64 `bun:"to_id,type:bigint,notnull," json:"to_id"`
RelFromID *ModelPublicThoughts `bun:"rel:has-one,join:from_id=id" json:"relfromid,omitempty"` // Has one ModelPublicThoughts RelFromID *ModelPublicThoughts `bun:"rel:has-one,join:from_id=id" json:"relfromid,omitempty"` // Has one ModelPublicThoughts
RelToID *ModelPublicThoughts `bun:"rel:has-one,join:to_id=id" json:"reltoid,omitempty"` // Has one ModelPublicThoughts RelToID *ModelPublicThoughts `bun:"rel:has-one,join:to_id=id" json:"reltoid,omitempty"` // Has one ModelPublicThoughts
} }
// TableName returns the table name for ModelPublicThoughtLinks // TableName returns the table name for ModelPublicThoughtLinks
@@ -40,7 +40,7 @@ func (m ModelPublicThoughtLinks) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicThoughtLinks) GetIDStr() string { func (m ModelPublicThoughtLinks) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
+19 -17
View File
@@ -3,26 +3,28 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicThoughts struct { type ModelPublicThoughts struct {
bun.BaseModel `bun:"table:public.thoughts,alias:thoughts"` bun.BaseModel `bun:"table:public.thoughts,alias:thoughts"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ArchivedAt resolvespec_common.SqlTimeStamp `bun:"archived_at,type:timestamptz,nullzero," json:"archived_at"` ArchivedAt sql_types.SqlTimeStamp `bun:"archived_at,type:timestamptz,nullzero," json:"archived_at"`
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"` Content sql_types.SqlString `bun:"content,type:text,notnull," json:"content"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"` GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Metadata resolvespec_common.SqlJSONB `bun:"metadata,type:jsonb,default:{}::jsonb,nullzero," json:"metadata"` Metadata sql_types.SqlJSONB `bun:"metadata,type:jsonb,default:{}::jsonb,nullzero," json:"metadata"`
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"` ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"` TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"`
RelFromIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=from_id" json:"relfromidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
RelToIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=to_id" json:"reltoidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks RelFromIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=from_id" json:"relfromidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks
RelThoughtIDPublicEmbeddings []*ModelPublicEmbeddings `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicembeddings,omitempty"` // Has many ModelPublicEmbeddings RelToIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=to_id" json:"reltoidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks
RelThoughtIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles RelThoughtIDPublicThoughtLearningLinks []*ModelPublicThoughtLearningLinks `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicthoughtlearninglinks,omitempty"` // Has many ModelPublicThoughtLearningLinks
RelRelatedThoughtIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_thought_id" json:"relrelatedthoughtidpubliclearnings,omitempty"` // Has many ModelPublicLearnings RelThoughtIDPublicEmbeddings []*ModelPublicEmbeddings `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicembeddings,omitempty"` // Has many ModelPublicEmbeddings
RelThoughtIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
RelRelatedThoughtIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_thought_id" json:"relrelatedthoughtidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
} }
// TableName returns the table name for ModelPublicThoughts // TableName returns the table name for ModelPublicThoughts
@@ -47,7 +49,7 @@ func (m ModelPublicThoughts) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicThoughts) GetIDStr() string { func (m ModelPublicThoughts) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
@@ -3,17 +3,17 @@ package generatedmodels
import ( import (
"fmt" "fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes" sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type ModelPublicToolAnnotations struct { type ModelPublicToolAnnotations struct {
bun.BaseModel `bun:"table:public.tool_annotations,alias:tool_annotations"` bun.BaseModel `bun:"table:public.tool_annotations,alias:tool_annotations"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"` ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"` CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,default:'',notnull," json:"notes"` Notes sql_types.SqlString `bun:"notes,type:text,default:'',notnull," json:"notes"`
ToolName resolvespec_common.SqlString `bun:"tool_name,type:text,notnull," json:"tool_name"` ToolName sql_types.SqlString `bun:"tool_name,type:text,notnull," json:"tool_name"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"` UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
} }
// TableName returns the table name for ModelPublicToolAnnotations // TableName returns the table name for ModelPublicToolAnnotations
@@ -38,7 +38,7 @@ func (m ModelPublicToolAnnotations) GetID() int64 {
// GetIDStr returns the primary key as a string // GetIDStr returns the primary key as a string
func (m ModelPublicToolAnnotations) GetIDStr() string { func (m ModelPublicToolAnnotations) GetIDStr() string {
return fmt.Sprintf("%v", m.ID) return m.ID.String()
} }
// SetID sets the primary key value // SetID sets the primary key value
+102 -24
View File
@@ -1,6 +1,7 @@
package mcpserver package mcpserver
import ( import (
"context"
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
@@ -18,31 +19,35 @@ const (
) )
type ToolSet struct { type ToolSet struct {
Version *tools.VersionTool Version *tools.VersionTool
Capture *tools.CaptureTool Capture *tools.CaptureTool
Search *tools.SearchTool Search *tools.SearchTool
List *tools.ListTool List *tools.ListTool
Stats *tools.StatsTool Stats *tools.StatsTool
Get *tools.GetTool Get *tools.GetTool
Update *tools.UpdateTool Update *tools.UpdateTool
Delete *tools.DeleteTool Delete *tools.DeleteTool
Archive *tools.ArchiveTool Archive *tools.ArchiveTool
Projects *tools.ProjectsTool DuplicateAudit *tools.DuplicateAuditTool
Context *tools.ContextTool Projects *tools.ProjectsTool
Recall *tools.RecallTool Context *tools.ContextTool
Summarize *tools.SummarizeTool Recall *tools.RecallTool
Links *tools.LinksTool Summarize *tools.SummarizeTool
Files *tools.FilesTool Links *tools.LinksTool
Backfill *tools.BackfillTool Files *tools.FilesTool
Reparse *tools.ReparseMetadataTool Backfill *tools.BackfillTool
RetryMetadata *tools.RetryEnrichmentTool Reparse *tools.ReparseMetadataTool
RetryMetadata *tools.RetryEnrichmentTool
//Maintenance *tools.MaintenanceTool //Maintenance *tools.MaintenanceTool
Skills *tools.SkillsTool Skills *tools.SkillsTool
Personas *tools.AgentPersonasTool Personas *tools.AgentPersonasTool
ChatHistory *tools.ChatHistoryTool ChatHistory *tools.ChatHistoryTool
Describe *tools.DescribeTool Describe *tools.DescribeTool
Learnings *tools.LearningsTool Learnings *tools.LearningsTool
Plans *tools.PlansTool Plans *tools.PlansTool
ProjectPersonas *tools.ProjectPersonasTool
WorldModel *tools.WorldModelTool
ThoughtLearningLinks *tools.ThoughtLearningLinksTool
} }
// Handlers groups the HTTP handlers produced for an MCP server instance. // Handlers groups the HTTP handlers produced for an MCP server instance.
@@ -86,7 +91,9 @@ func NewHandlers(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onS
registerSystemTools, registerSystemTools,
registerThoughtTools, registerThoughtTools,
registerProjectTools, registerProjectTools,
registerWorldModelTools,
registerLearningTools, registerLearningTools,
registerThoughtLearningLinkTools,
registerPlanTools, registerPlanTools,
registerFileTools, registerFileTools,
registerMaintenanceTools, registerMaintenanceTools,
@@ -123,6 +130,33 @@ func NewHandlers(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onS
return h, nil return h, nil
} }
func registerWorldModelTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
server.AddResource(&mcp.Resource{
Name: "world_model_intro",
URI: "amcs://world-model/intro",
MIMEType: "text/markdown",
Description: "Mandatory startup instructions for discovering and loading the AMCS world model.",
}, func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{URI: req.Params.URI, MIMEType: "text/markdown", Text: string(amcsllm.WorldModelIntro)}}}, nil
})
if err := addTool(server, logger, &mcp.Tool{Name: "bootstrap_world_model", Description: "Validate and activate a project, then return its effective skills, guardrails, personas, and bounded recent context."}, toolSet.WorldModel.Bootstrap); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{Name: "add_project_persona", Description: "Link a persona to a project and optionally make it the default."}, toolSet.ProjectPersonas.Add); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{Name: "remove_project_persona", Description: "Unlink a persona from a project."}, toolSet.ProjectPersonas.Remove); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{Name: "set_default_project_persona", Description: "Set a linked persona as the project's default persona."}, toolSet.ProjectPersonas.SetDefault); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{Name: "list_project_personas", Description: "List personas linked to a project, with the default marked."}, toolSet.ProjectPersonas.List); err != nil {
return err
}
return nil
}
// buildServerIcons returns icon definitions referencing the server's own /images/icon.png endpoint. // buildServerIcons returns icon definitions referencing the server's own /images/icon.png endpoint.
// Returns nil when publicURL is empty so the icons field is omitted from the MCP identity. // Returns nil when publicURL is empty so the icons field is omitted from the MCP identity.
func buildServerIcons(publicURL string) []mcp.Icon { func buildServerIcons(publicURL string) []mcp.Icon {
@@ -194,6 +228,12 @@ func registerThoughtTools(server *mcp.Server, logger *slog.Logger, toolSet ToolS
}, toolSet.Archive.Handle); err != nil { }, toolSet.Archive.Handle); err != nil {
return err return err
} }
if err := addTool(server, logger, &mcp.Tool{
Name: "audit_duplicates",
Description: "Dry-run duplicate audit for projects, thoughts, and metadata normalization candidates; performs no writes.",
}, toolSet.DuplicateAudit.Handle); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{ if err := addTool(server, logger, &mcp.Tool{
Name: "summarize_thoughts", Name: "summarize_thoughts",
Description: "LLM summary of a filtered set of thoughts.", Description: "LLM summary of a filtered set of thoughts.",
@@ -277,6 +317,34 @@ func registerLearningTools(server *mcp.Server, logger *slog.Logger, toolSet Tool
return nil return nil
} }
func registerThoughtLearningLinkTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
if err := addTool(server, logger, &mcp.Tool{
Name: "link_thought_learning",
Description: "Create or update an explicit association between a raw thought and a curated learning.",
}, toolSet.ThoughtLearningLinks.Link); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "unlink_thought_learning",
Description: "Remove an explicit association between a thought and a learning.",
}, toolSet.ThoughtLearningLinks.Unlink); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "get_thought_learnings",
Description: "List curated learnings explicitly linked to a thought.",
}, toolSet.ThoughtLearningLinks.GetThoughtLearnings); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "get_learning_thoughts",
Description: "List raw thoughts explicitly linked to a learning.",
}, toolSet.ThoughtLearningLinks.GetLearningThoughts); err != nil {
return err
}
return nil
}
func registerPlanTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { func registerPlanTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
if err := addTool(server, logger, &mcp.Tool{ if err := addTool(server, logger, &mcp.Tool{
Name: "create_plan", Name: "create_plan",
@@ -703,6 +771,7 @@ func BuildToolCatalog() []tools.ToolEntry {
{Name: "update_thought", Description: "Update thought content or merge metadata.", Category: "thoughts"}, {Name: "update_thought", Description: "Update thought content or merge metadata.", Category: "thoughts"},
{Name: "delete_thought", Description: "Hard-delete a thought by id.", Category: "thoughts"}, {Name: "delete_thought", Description: "Hard-delete a thought by id.", Category: "thoughts"},
{Name: "archive_thought", Description: "Archive a thought so it is hidden from default search and listing.", Category: "thoughts"}, {Name: "archive_thought", Description: "Archive a thought so it is hidden from default search and listing.", Category: "thoughts"},
{Name: "audit_duplicates", Description: "Dry-run duplicate audit for exact/normalized project names, thought content, and metadata value variants. Reports candidates and recommendations; performs no writes.", Category: "admin"},
{Name: "summarize_thoughts", Description: "Produce an LLM prose summary of a filtered or searched set of thoughts.", Category: "thoughts"}, {Name: "summarize_thoughts", Description: "Produce an LLM prose summary of a filtered or searched set of thoughts.", Category: "thoughts"},
{Name: "recall_context", Description: "Recall semantically relevant and recent context for prompt injection. Combines vector similarity with recency. Falls back to full-text search when no embeddings exist.", Category: "thoughts"}, {Name: "recall_context", Description: "Recall semantically relevant and recent context for prompt injection. Combines vector similarity with recency. Falls back to full-text search when no embeddings exist.", Category: "thoughts"},
{Name: "link_thoughts", Description: "Create a typed relationship between two thoughts.", Category: "thoughts"}, {Name: "link_thoughts", Description: "Create a typed relationship between two thoughts.", Category: "thoughts"},
@@ -713,12 +782,21 @@ func BuildToolCatalog() []tools.ToolEntry {
{Name: "list_projects", Description: "List projects and their current thought counts.", Category: "projects"}, {Name: "list_projects", Description: "List projects and their current thought counts.", Category: "projects"},
{Name: "set_active_project", Description: "Set the active project for the current MCP session. Requires a stateful MCP client that reuses the same session across calls. If your client does not preserve sessions, pass project explicitly to each tool instead.", Category: "projects"}, {Name: "set_active_project", Description: "Set the active project for the current MCP session. Requires a stateful MCP client that reuses the same session across calls. If your client does not preserve sessions, pass project explicitly to each tool instead.", Category: "projects"},
{Name: "get_active_project", Description: "Return the active project for the current MCP session. If your client does not preserve MCP sessions, pass project explicitly to project-scoped tools instead of relying on this.", Category: "projects"}, {Name: "get_active_project", Description: "Return the active project for the current MCP session. If your client does not preserve MCP sessions, pass project explicitly to project-scoped tools instead of relying on this.", Category: "projects"},
{Name: "bootstrap_world_model", Description: "Validate and activate a project, then return effective skills, guardrails, personas, and bounded recent context.", Category: "projects"},
{Name: "add_project_persona", Description: "Link a persona to a project and optionally make it the default.", Category: "personas"},
{Name: "remove_project_persona", Description: "Unlink a persona from a project.", Category: "personas"},
{Name: "set_default_project_persona", Description: "Set a linked persona as the project's default persona.", Category: "personas"},
{Name: "list_project_personas", Description: "List personas linked to a project, with the default marked.", Category: "personas"},
{Name: "get_project_context", Description: "Get recent and semantic context for a project. Uses the explicit project when provided, otherwise the active MCP session project. Falls back to full-text search when no embeddings exist.", Category: "projects"}, {Name: "get_project_context", Description: "Get recent and semantic context for a project. Uses the explicit project when provided, otherwise the active MCP session project. Falls back to full-text search when no embeddings exist.", Category: "projects"},
// learnings // learnings
{Name: "add_learning", Description: "Create a curated learning record distinct from raw thoughts.", Category: "projects"}, {Name: "add_learning", Description: "Create a curated learning record distinct from raw thoughts.", Category: "projects"},
{Name: "get_learning", Description: "Retrieve a structured learning by id.", Category: "projects"}, {Name: "get_learning", Description: "Retrieve a structured learning by id.", Category: "projects"},
{Name: "list_learnings", Description: "List structured learnings with optional project, category, area, status, priority, tag, and text filters.", Category: "projects"}, {Name: "list_learnings", Description: "List structured learnings with optional project, category, area, status, priority, tag, and text filters.", Category: "projects"},
{Name: "link_thought_learning", Description: "Create or update a lightweight explicit association between a raw thought and a curated learning.", Category: "projects"},
{Name: "unlink_thought_learning", Description: "Remove a lightweight explicit association between a thought and a learning.", Category: "projects"},
{Name: "get_thought_learnings", Description: "List curated learnings explicitly associated with a thought.", Category: "projects"},
{Name: "get_learning_thoughts", Description: "List raw source thoughts explicitly associated with a learning.", Category: "projects"},
// plans // plans
{Name: "create_plan", Description: "Create a structured plan with status, priority, owner, due date, and optional project link.", Category: "plans"}, {Name: "create_plan", Description: "Create a structured plan with status, priority, owner, due date, and optional project link.", Category: "plans"},
+21
View File
@@ -5,6 +5,7 @@ import (
"net/http/httptest" "net/http/httptest"
"reflect" "reflect"
"sort" "sort"
"strings"
"testing" "testing"
"time" "time"
@@ -60,6 +61,26 @@ func TestNewListsStoredFileResourceTemplate(t *testing.T) {
} }
} }
func TestNewExposesWorldModelIntroResource(t *testing.T) {
cs := newStreamableTestClient(t)
listed, err := cs.ListResources(context.Background(), nil)
if err != nil {
t.Fatalf("ListResources() error = %v", err)
}
if len(listed.Resources) != 1 || listed.Resources[0].URI != "amcs://world-model/intro" {
t.Fatalf("ListResources() = %#v, want world model intro", listed.Resources)
}
read, err := cs.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: "amcs://world-model/intro"})
if err != nil {
t.Fatalf("ReadResource() error = %v", err)
}
if len(read.Contents) != 1 || !strings.Contains(read.Contents[0].Text, "bootstrap_world_model") {
t.Fatalf("ReadResource() did not return bootstrap instructions: %#v", read.Contents)
}
}
func newStreamableTestClient(t *testing.T) *mcp.ClientSession { func newStreamableTestClient(t *testing.T) *mcp.ClientSession {
t.Helper() t.Helper()
@@ -189,28 +189,31 @@ func TestStreamableHTTPReturnsStructuredToolErrors(t *testing.T) {
func streamableTestToolSet() ToolSet { func streamableTestToolSet() ToolSet {
return ToolSet{ return ToolSet{
Version: tools.NewVersionTool("test", buildinfo.Info{Version: "0.0.1", TagName: "v0.0.1", Commit: "test", BuildDate: "2026-03-31T00:00:00Z"}), Version: tools.NewVersionTool("test", buildinfo.Info{Version: "0.0.1", TagName: "v0.0.1", Commit: "test", BuildDate: "2026-03-31T00:00:00Z"}),
Capture: new(tools.CaptureTool), Capture: new(tools.CaptureTool),
Search: new(tools.SearchTool), Search: new(tools.SearchTool),
List: new(tools.ListTool), List: new(tools.ListTool),
Stats: new(tools.StatsTool), Stats: new(tools.StatsTool),
Get: new(tools.GetTool), Get: new(tools.GetTool),
Update: new(tools.UpdateTool), Update: new(tools.UpdateTool),
Delete: new(tools.DeleteTool), Delete: new(tools.DeleteTool),
Archive: new(tools.ArchiveTool), Archive: new(tools.ArchiveTool),
Projects: new(tools.ProjectsTool), Projects: new(tools.ProjectsTool),
Context: new(tools.ContextTool), Context: new(tools.ContextTool),
Recall: new(tools.RecallTool), Recall: new(tools.RecallTool),
Summarize: new(tools.SummarizeTool), Summarize: new(tools.SummarizeTool),
Links: new(tools.LinksTool), Links: new(tools.LinksTool),
Files: new(tools.FilesTool), Files: new(tools.FilesTool),
Backfill: new(tools.BackfillTool), Backfill: new(tools.BackfillTool),
Reparse: new(tools.ReparseMetadataTool), Reparse: new(tools.ReparseMetadataTool),
RetryMetadata: new(tools.RetryEnrichmentTool), RetryMetadata: new(tools.RetryEnrichmentTool),
Skills: new(tools.SkillsTool), Skills: new(tools.SkillsTool),
ChatHistory: new(tools.ChatHistoryTool), ChatHistory: new(tools.ChatHistoryTool),
Describe: new(tools.DescribeTool), Describe: new(tools.DescribeTool),
Learnings: new(tools.LearningsTool), Learnings: new(tools.LearningsTool),
Plans: new(tools.PlansTool), Plans: new(tools.PlansTool),
ProjectPersonas: new(tools.ProjectPersonasTool),
WorldModel: new(tools.WorldModelTool),
ThoughtLearningLinks: new(tools.ThoughtLearningLinksTool),
} }
} }
+22
View File
@@ -53,6 +53,7 @@ func Normalize(in thoughttypes.ThoughtMetadata, capture config.CaptureConfig) th
Type: normalizeType(in.Type), Type: normalizeType(in.Type),
Source: normalizeSource(in.Source), Source: normalizeSource(in.Source),
Attachments: normalizeAttachments(in.Attachments), Attachments: normalizeAttachments(in.Attachments),
Webhook: normalizeWebhook(in.Webhook),
MetadataStatus: normalizeMetadataStatus(in.MetadataStatus), MetadataStatus: normalizeMetadataStatus(in.MetadataStatus),
MetadataUpdatedAt: strings.TrimSpace(in.MetadataUpdatedAt), MetadataUpdatedAt: strings.TrimSpace(in.MetadataUpdatedAt),
MetadataLastAttemptedAt: strings.TrimSpace(in.MetadataLastAttemptedAt), MetadataLastAttemptedAt: strings.TrimSpace(in.MetadataLastAttemptedAt),
@@ -201,10 +202,31 @@ func Merge(base, patch thoughttypes.ThoughtMetadata, capture config.CaptureConfi
if len(patch.Attachments) > 0 { if len(patch.Attachments) > 0 {
merged.Attachments = append(append([]thoughttypes.ThoughtAttachment{}, merged.Attachments...), patch.Attachments...) merged.Attachments = append(append([]thoughttypes.ThoughtAttachment{}, merged.Attachments...), patch.Attachments...)
} }
if patch.Webhook != nil {
merged.Webhook = patch.Webhook
}
return Normalize(merged, capture) return Normalize(merged, capture)
} }
func normalizeWebhook(value *thoughttypes.WebhookMetadata) *thoughttypes.WebhookMetadata {
if value == nil {
return nil
}
out := &thoughttypes.WebhookMetadata{
ReceivedAt: strings.TrimSpace(value.ReceivedAt),
IDempotencyKey: strings.TrimSpace(value.IDempotencyKey),
ExternalID: strings.TrimSpace(value.ExternalID),
}
if len(value.SourceMetadata) > 0 {
out.SourceMetadata = value.SourceMetadata
}
if out.ReceivedAt == "" && out.IDempotencyKey == "" && out.ExternalID == "" && len(out.SourceMetadata) == 0 {
return nil
}
return out
}
func normalizeAttachments(values []thoughttypes.ThoughtAttachment) []thoughttypes.ThoughtAttachment { func normalizeAttachments(values []thoughttypes.ThoughtAttachment) []thoughttypes.ThoughtAttachment {
seen := make(map[string]struct{}, len(values)) seen := make(map[string]struct{}, len(values))
result := make([]thoughttypes.ThoughtAttachment, 0, len(values)) result := make([]thoughttypes.ThoughtAttachment, 0, len(values))
+10 -6
View File
@@ -234,6 +234,7 @@ func (db *DB) GetPersona(ctx context.Context, name string, detail bool, override
Name: s.Name, Name: s.Name,
Description: s.Description, Description: s.Description,
Tags: s.Tags, Tags: s.Tags,
Override: s.Override,
} }
if detail { if detail {
entry.Content = s.Content entry.Content = s.Content
@@ -341,6 +342,7 @@ func (db *DB) GetPersonaManifest(ctx context.Context, name string) (ext.PersonaM
ID: s.ID, ID: s.ID,
Name: s.Name, Name: s.Name,
Description: s.Description, Description: s.Description,
Override: s.Override,
}) })
} }
for _, g := range guardrails { for _, g := range guardrails {
@@ -567,11 +569,12 @@ func (db *DB) RemovePersonaPart(ctx context.Context, personaName, partName strin
// Persona-Skill links // Persona-Skill links
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
func (db *DB) AddPersonaSkill(ctx context.Context, personaID, skillID int64) error { func (db *DB) AddPersonaSkill(ctx context.Context, personaID, skillID int64, override bool) error {
_, err := db.pool.Exec(ctx, ` _, err := db.pool.Exec(ctx, `
insert into agent_persona_skills (persona_id, skill_id) insert into agent_persona_skills (persona_id, skill_id, override)
values ($1, $2) on conflict do nothing values ($1, $2, $3)
`, personaID, skillID) on conflict (persona_id, skill_id) do update set override = excluded.override
`, personaID, skillID, override)
if err != nil { if err != nil {
return fmt.Errorf("add persona skill: %w", err) return fmt.Errorf("add persona skill: %w", err)
} }
@@ -1058,7 +1061,7 @@ func (db *DB) fetchPartsByNames(ctx context.Context, names []string) ([]rawPart,
func (db *DB) listPersonaSkills(ctx context.Context, personaID int64) ([]AgentSkillRow, error) { func (db *DB) listPersonaSkills(ctx context.Context, personaID int64) ([]AgentSkillRow, error) {
rows, err := db.pool.Query(ctx, ` rows, err := db.pool.Query(ctx, `
select s.id, s.name, s.description, s.content, s.tags::text[] select s.id, s.name, s.description, s.content, s.tags::text[], aps.override
from agent_skills s from agent_skills s
join agent_persona_skills aps on aps.skill_id = s.id join agent_persona_skills aps on aps.skill_id = s.id
where aps.persona_id = $1 where aps.persona_id = $1
@@ -1073,7 +1076,7 @@ func (db *DB) listPersonaSkills(ctx context.Context, personaID int64) ([]AgentSk
for rows.Next() { for rows.Next() {
var s AgentSkillRow var s AgentSkillRow
var tags []string var tags []string
if err := rows.Scan(&s.ID, &s.Name, &s.Description, &s.Content, &tags); err != nil { if err := rows.Scan(&s.ID, &s.Name, &s.Description, &s.Content, &tags, &s.Override); err != nil {
return nil, fmt.Errorf("scan persona skill: %w", err) return nil, fmt.Errorf("scan persona skill: %w", err)
} }
s.Tags = nilToEmptyStrings(tags) s.Tags = nilToEmptyStrings(tags)
@@ -1139,6 +1142,7 @@ type AgentSkillRow struct {
Description string Description string
Content string Content string
Tags []string Tags []string
Override bool
} }
type AgentGuardrailRow struct { type AgentGuardrailRow struct {
+6 -5
View File
@@ -15,10 +15,10 @@ import (
func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile) (thoughttypes.StoredFile, error) { func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile) (thoughttypes.StoredFile, error) {
row := db.pool.QueryRow(ctx, ` row := db.pool.QueryRow(ctx, `
insert into stored_files (thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, content) insert into stored_files (thought_id, project_id, tenant_key, name, media_type, kind, encoding, size_bytes, sha256, content)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
returning id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, created_at, updated_at returning id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, created_at, updated_at
`, file.ThoughtID, file.ProjectID, file.Name, file.MediaType, file.Kind, file.Encoding, file.SizeBytes, file.SHA256, file.Content) `, file.ThoughtID, file.ProjectID, tenantKeyPtr(ctx), file.Name, file.MediaType, file.Kind, file.Encoding, file.SizeBytes, file.SHA256, file.Content)
var model generatedmodels.ModelPublicStoredFiles var model generatedmodels.ModelPublicStoredFiles
if err := row.Scan( if err := row.Scan(
@@ -42,11 +42,11 @@ func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile
} }
func (db *DB) GetStoredFile(ctx context.Context, id uuid.UUID) (thoughttypes.StoredFile, error) { func (db *DB) GetStoredFile(ctx context.Context, id uuid.UUID) (thoughttypes.StoredFile, error) {
args := []any{id}
row := db.pool.QueryRow(ctx, ` row := db.pool.QueryRow(ctx, `
select id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, content, created_at, updated_at select id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, content, created_at, updated_at
from stored_files from stored_files
where guid = $1 where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
`, id)
var model generatedmodels.ModelPublicStoredFiles var model generatedmodels.ModelPublicStoredFiles
if err := row.Scan( if err := row.Scan(
@@ -77,6 +77,7 @@ func (db *DB) ListStoredFiles(ctx context.Context, filter thoughttypes.StoredFil
args := make([]any, 0, 4) args := make([]any, 0, 4)
conditions := make([]string, 0, 3) conditions := make([]string, 0, 3)
addTenantCondition(ctx, &args, &conditions, "tenant_key")
if filter.ThoughtID != nil { if filter.ThoughtID != nil {
args = append(args, *filter.ThoughtID) args = append(args, *filter.ThoughtID)
conditions = append(conditions, fmt.Sprintf("thought_id = $%d", len(args))) conditions = append(conditions, fmt.Sprintf("thought_id = $%d", len(args)))
+99
View File
@@ -0,0 +1,99 @@
package store
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
func (db *DB) AddProjectPersona(ctx context.Context, projectID, personaID int64, isDefault bool) error {
tx, err := db.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin add project persona: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `select pg_advisory_xact_lock($1)`, projectID); err != nil {
return fmt.Errorf("lock project persona defaults: %w", err)
}
if isDefault {
if _, err := tx.Exec(ctx, `update project_personas set is_default = false where project_id = $1`, projectID); err != nil {
return fmt.Errorf("clear default project persona: %w", err)
}
}
if _, err := tx.Exec(ctx, `
insert into project_personas (project_id, persona_id, is_default)
values ($1, $2, $3)
on conflict (project_id, persona_id) do update set is_default = excluded.is_default
`, projectID, personaID, isDefault); err != nil {
return fmt.Errorf("add project persona: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit add project persona: %w", err)
}
return nil
}
func (db *DB) RemoveProjectPersona(ctx context.Context, projectID, personaID int64) error {
tag, err := db.pool.Exec(ctx, `delete from project_personas where project_id = $1 and persona_id = $2`, projectID, personaID)
if err != nil {
return fmt.Errorf("remove project persona: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("project persona link not found")
}
return nil
}
func (db *DB) ListProjectPersonas(ctx context.Context, projectID int64) ([]ext.ProjectPersona, error) {
rows, err := db.pool.Query(ctx, `
select p.id, p.guid, p.name, p.description, p.summary, p.detail,
p.compiled_summary, p.compiled_detail, p.compiled_at, p.tags::text[],
p.created_at, p.updated_at, pp.is_default
from agent_personas p
join project_personas pp on pp.persona_id = p.id
where pp.project_id = $1
order by pp.is_default desc, p.name
`, projectID)
if err != nil {
return nil, fmt.Errorf("list project personas: %w", err)
}
defer rows.Close()
var result []ext.ProjectPersona
for rows.Next() {
var item ext.ProjectPersona
var tags []string
if err := rows.Scan(&item.Persona.ID, &item.Persona.GUID, &item.Persona.Name,
&item.Persona.Description, &item.Persona.Summary, &item.Persona.Detail,
&item.Persona.CompiledSummary, &item.Persona.CompiledDetail, &item.Persona.CompiledAt,
&tags, &item.Persona.CreatedAt, &item.Persona.UpdatedAt, &item.IsDefault); err != nil {
return nil, fmt.Errorf("scan project persona: %w", err)
}
item.Persona.Tags = nilToEmptyStrings(tags)
result = append(result, item)
}
return result, rows.Err()
}
func (db *DB) GetDefaultProjectPersona(ctx context.Context, projectID int64) (*ext.Persona, error) {
row := db.pool.QueryRow(ctx, `
select p.id, p.guid, p.name, p.description, p.summary, p.detail,
p.compiled_summary, p.compiled_detail, p.compiled_at, p.tags::text[],
p.created_at, p.updated_at
from agent_personas p
join project_personas pp on pp.persona_id = p.id
where pp.project_id = $1 and pp.is_default
`, projectID)
persona, err := scanPersona(row)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get default project persona: %w", err)
}
return &persona, nil
}
+17 -9
View File
@@ -14,10 +14,10 @@ import (
func (db *DB) CreateProject(ctx context.Context, name, description string) (thoughttypes.Project, error) { func (db *DB) CreateProject(ctx context.Context, name, description string) (thoughttypes.Project, error) {
row := db.pool.QueryRow(ctx, ` row := db.pool.QueryRow(ctx, `
insert into projects (name, description) insert into projects (name, description, tenant_key)
values ($1, $2) values ($1, $2, $3)
returning id, guid, name, description, created_at, last_active_at returning id, guid, name, description, created_at, last_active_at
`, name, description) `, name, description, tenantKeyPtr(ctx))
var model generatedmodels.ModelPublicProjects var model generatedmodels.ModelPublicProjects
if err := row.Scan(&model.ID, &model.GUID, &model.Name, &model.Description, &model.CreatedAt, &model.LastActiveAt); err != nil { if err := row.Scan(&model.ID, &model.GUID, &model.Name, &model.Description, &model.CreatedAt, &model.LastActiveAt); err != nil {
@@ -45,20 +45,20 @@ func (db *DB) GetProject(ctx context.Context, nameOrID string) (thoughttypes.Pro
} }
func (db *DB) getProjectByGUID(ctx context.Context, id uuid.UUID) (thoughttypes.Project, error) { func (db *DB) getProjectByGUID(ctx context.Context, id uuid.UUID) (thoughttypes.Project, error) {
args := []any{id}
row := db.pool.QueryRow(ctx, ` row := db.pool.QueryRow(ctx, `
select id, guid, name, description, created_at, last_active_at select id, guid, name, description, created_at, last_active_at
from projects from projects
where guid = $1 where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
`, id)
return scanProject(row) return scanProject(row)
} }
func (db *DB) getProjectByName(ctx context.Context, name string) (thoughttypes.Project, error) { func (db *DB) getProjectByName(ctx context.Context, name string) (thoughttypes.Project, error) {
args := []any{name}
row := db.pool.QueryRow(ctx, ` row := db.pool.QueryRow(ctx, `
select id, guid, name, description, created_at, last_active_at select id, guid, name, description, created_at, last_active_at
from projects from projects
where name = $1 where name = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
`, name)
return scanProject(row) return scanProject(row)
} }
@@ -74,13 +74,20 @@ func scanProject(row pgx.Row) (thoughttypes.Project, error) {
} }
func (db *DB) ListProjects(ctx context.Context) ([]thoughttypes.ProjectSummary, error) { func (db *DB) ListProjects(ctx context.Context) ([]thoughttypes.ProjectSummary, error) {
args := []any{}
where := ""
if key, ok := tenantKey(ctx); ok {
args = append(args, key)
where = "where p.tenant_key = $1"
}
rows, err := db.pool.Query(ctx, ` rows, err := db.pool.Query(ctx, `
select p.id, p.guid, p.name, p.description, p.created_at, p.last_active_at, count(t.id) as thought_count select p.id, p.guid, p.name, p.description, p.created_at, p.last_active_at, count(t.id) as thought_count
from projects p from projects p
left join thoughts t on t.project_id = p.id and t.archived_at is null left join thoughts t on t.project_id = p.id and t.archived_at is null
`+where+`
group by p.id, p.guid, p.name, p.description, p.created_at, p.last_active_at group by p.id, p.guid, p.name, p.description, p.created_at, p.last_active_at
order by p.last_active_at desc, p.created_at desc order by p.last_active_at desc, p.created_at desc
`) `, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("list projects: %w", err) return nil, fmt.Errorf("list projects: %w", err)
} }
@@ -105,7 +112,8 @@ func (db *DB) ListProjects(ctx context.Context) ([]thoughttypes.ProjectSummary,
} }
func (db *DB) TouchProject(ctx context.Context, id int64) error { func (db *DB) TouchProject(ctx context.Context, id int64) error {
tag, err := db.pool.Exec(ctx, `update projects set last_active_at = now() where id = $1`, id) args := []any{id}
tag, err := db.pool.Exec(ctx, `update projects set last_active_at = now() where id = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
if err != nil { if err != nil {
return fmt.Errorf("touch project: %w", err) return fmt.Errorf("touch project: %w", err)
} }
+31 -8
View File
@@ -116,6 +116,24 @@ func scanSkill(row skillScanner) (ext.AgentSkill, error) {
}, nil }, nil
} }
func normalizeSkillSlices(skill *ext.AgentSkill) {
if skill.Tags == nil {
skill.Tags = []string{}
}
if skill.LanguageTags == nil {
skill.LanguageTags = []string{}
}
if skill.LibraryTags == nil {
skill.LibraryTags = []string{}
}
if skill.FrameworkTags == nil {
skill.FrameworkTags = []string{}
}
if skill.DomainTags == nil {
skill.DomainTags = []string{}
}
}
func (db *DB) GetSkill(ctx context.Context, id int64) (ext.AgentSkill, error) { func (db *DB) GetSkill(ctx context.Context, id int64) (ext.AgentSkill, error) {
row := db.pool.QueryRow(ctx, `select `+skillSelectCols+` from agent_skills where id = $1`, id) row := db.pool.QueryRow(ctx, `select `+skillSelectCols+` from agent_skills where id = $1`, id)
s, err := scanSkill(row) s, err := scanSkill(row)
@@ -278,12 +296,12 @@ func (db *DB) GetGuardrail(ctx context.Context, id int64) (ext.AgentGuardrail, e
// Project Skills // Project Skills
func (db *DB) AddProjectSkill(ctx context.Context, projectID, skillID int64) error { func (db *DB) AddProjectSkill(ctx context.Context, projectID, skillID int64, override bool) error {
_, err := db.pool.Exec(ctx, ` _, err := db.pool.Exec(ctx, `
insert into project_skills (project_id, skill_id) insert into project_skills (project_id, skill_id, override)
values ($1, $2) values ($1, $2, $3)
on conflict do nothing on conflict (project_id, skill_id) do update set override = excluded.override
`, projectID, skillID) `, projectID, skillID, override)
if err != nil { if err != nil {
return fmt.Errorf("add project skill: %w", err) return fmt.Errorf("add project skill: %w", err)
} }
@@ -305,7 +323,9 @@ func (db *DB) RemoveProjectSkill(ctx context.Context, projectID, skillID int64)
func (db *DB) ListProjectSkills(ctx context.Context, projectID int64) ([]ext.AgentSkill, error) { func (db *DB) ListProjectSkills(ctx context.Context, projectID int64) ([]ext.AgentSkill, error) {
rows, err := db.pool.Query(ctx, ` rows, err := db.pool.Query(ctx, `
select s.`+skillSelectCols+` select s.id, s.name, s.description, s.content, s.tags::text[],
s.language_tags::text[], s.library_tags::text[], s.framework_tags::text[],
s.domain_tags::text[], s.created_at, s.updated_at, ps.override
from agent_skills s from agent_skills s
join project_skills ps on ps.skill_id = s.id join project_skills ps on ps.skill_id = s.id
where ps.project_id = $1 where ps.project_id = $1
@@ -318,10 +338,13 @@ func (db *DB) ListProjectSkills(ctx context.Context, projectID int64) ([]ext.Age
var skills []ext.AgentSkill var skills []ext.AgentSkill
for rows.Next() { for rows.Next() {
s, err := scanSkill(rows) var s ext.AgentSkill
if err != nil { if err := rows.Scan(&s.ID, &s.Name, &s.Description, &s.Content, &s.Tags,
&s.LanguageTags, &s.LibraryTags, &s.FrameworkTags, &s.DomainTags,
&s.CreatedAt, &s.UpdatedAt, &s.Override); err != nil {
return nil, fmt.Errorf("scan project skill: %w", err) return nil, fmt.Errorf("scan project skill: %w", err)
} }
normalizeSkillSlices(&s)
skills = append(skills, s) skills = append(skills, s)
} }
return skills, rows.Err() return skills, rows.Err()
+34
View File
@@ -0,0 +1,34 @@
package store
import (
"context"
"fmt"
"git.warky.dev/wdevs/amcs/internal/tenancy"
)
func tenantKeyPtr(ctx context.Context) *string {
if key, ok := tenancy.KeyFromContext(ctx); ok {
return &key
}
return nil
}
func tenantKey(ctx context.Context) (string, bool) {
return tenancy.KeyFromContext(ctx)
}
func addTenantCondition(ctx context.Context, args *[]any, conditions *[]string, column string) {
if key, ok := tenancy.KeyFromContext(ctx); ok {
*args = append(*args, key)
*conditions = append(*conditions, fmt.Sprintf("%s = $%d", column, len(*args)))
}
}
func tenantSQL(ctx context.Context, args *[]any, column string) string {
if key, ok := tenancy.KeyFromContext(ctx); ok {
*args = append(*args, key)
return fmt.Sprintf(" and %s = $%d", column, len(*args))
}
return ""
}
+148
View File
@@ -0,0 +1,148 @@
package store
import (
"context"
"fmt"
"github.com/google/uuid"
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
// LinkThoughtLearning creates or updates an association between a thought (by GUID) and a learning (by numeric ID).
// If the pair already exists, the relation label is updated.
func (db *DB) LinkThoughtLearning(ctx context.Context, thoughtGUID uuid.UUID, learningID int64, relation string) error {
_, err := db.pool.Exec(ctx, `
insert into thought_learning_links (thought_id, learning_id, relation)
select t.id, $2, $3
from thoughts t
where t.guid = $1
on conflict (thought_id, learning_id) do update set relation = excluded.relation
`, thoughtGUID, learningID, relation)
if err != nil {
return fmt.Errorf("link thought learning: %w", err)
}
return nil
}
// UnlinkThoughtLearning removes the association between a thought (by GUID) and a learning (by numeric ID).
func (db *DB) UnlinkThoughtLearning(ctx context.Context, thoughtGUID uuid.UUID, learningID int64) error {
_, err := db.pool.Exec(ctx, `
delete from thought_learning_links
where thought_id = (select id from thoughts where guid = $1)
and learning_id = $2
`, thoughtGUID, learningID)
if err != nil {
return fmt.Errorf("unlink thought learning: %w", err)
}
return nil
}
// GetLinkedLearnings returns all learnings explicitly associated with a thought (by GUID).
func (db *DB) GetLinkedLearnings(ctx context.Context, thoughtGUID uuid.UUID) ([]thoughttypes.LinkedLearning, error) {
rows, err := db.pool.Query(ctx, `
select l.id, l.guid, l.summary, l.details, l.category, l.area, l.status, l.priority, l.confidence,
l.action_required, l.source_type, l.source_ref, l.project_id, l.related_thought_id,
l.related_skill_id, l.reviewed_by, l.reviewed_at, l.duplicate_of_learning_id,
l.supersedes_learning_id, l.tags::text[], l.created_at, l.updated_at,
tll.relation, tll.created_at as linked_at
from thought_learning_links tll
join learnings l on l.id = tll.learning_id
where tll.thought_id = (select id from thoughts where guid = $1)
order by tll.created_at desc
`, thoughtGUID)
if err != nil {
return nil, fmt.Errorf("query linked learnings: %w", err)
}
defer rows.Close()
items := make([]thoughttypes.LinkedLearning, 0)
for rows.Next() {
var tags []string
var linked thoughttypes.LinkedLearning
var m generatedmodels.ModelPublicLearnings
if err := rows.Scan(
&m.ID,
&m.GUID,
&m.Summary,
&m.Details,
&m.Category,
&m.Area,
&m.Status,
&m.Priority,
&m.Confidence,
&m.ActionRequired,
&m.SourceType,
&m.SourceRef,
&m.ProjectID,
&m.RelatedThoughtID,
&m.RelatedSkillID,
&m.ReviewedBy,
&m.ReviewedAt,
&m.DuplicateOfLearningID,
&m.SupersedesLearningID,
&tags,
&m.CreatedAt,
&m.UpdatedAt,
&linked.Relation,
&linked.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan linked learning: %w", err)
}
linked.Learning = learningFromModel(m, tags)
items = append(items, linked)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate linked learnings: %w", err)
}
return items, nil
}
// GetLinkedThoughtsForLearning returns all thoughts explicitly associated with a learning (by numeric ID).
func (db *DB) GetLinkedThoughtsForLearning(ctx context.Context, learningID int64) ([]thoughttypes.LinkedLearningThought, error) {
rows, err := db.pool.Query(ctx, `
select t.id, t.guid, t.content, t.metadata, t.project_id, t.archived_at, t.created_at, t.updated_at,
tll.relation, tll.created_at as linked_at
from thought_learning_links tll
join thoughts t on t.id = tll.thought_id
where tll.learning_id = $1
order by tll.created_at desc
`, learningID)
if err != nil {
return nil, fmt.Errorf("query linked thoughts for learning: %w", err)
}
defer rows.Close()
items := make([]thoughttypes.LinkedLearningThought, 0)
for rows.Next() {
var linked thoughttypes.LinkedLearningThought
var m generatedmodels.ModelPublicThoughts
if err := rows.Scan(
&m.ID,
&m.GUID,
&m.Content,
&m.Metadata,
&m.ProjectID,
&m.ArchivedAt,
&m.CreatedAt,
&m.UpdatedAt,
&linked.Relation,
&linked.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan linked thought for learning: %w", err)
}
thought, err := thoughtFromModel(m)
if err != nil {
return nil, fmt.Errorf("map linked thought for learning: %w", err)
}
linked.Thought = thought
items = append(items, linked)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate linked thoughts for learning: %w", err)
}
return items, nil
}
+42 -15
View File
@@ -31,10 +31,10 @@ func (db *DB) InsertThought(ctx context.Context, thought thoughttypes.Thought, e
}() }()
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
insert into thoughts (content, metadata, project_id) insert into thoughts (content, metadata, project_id, tenant_key)
values ($1, $2::jsonb, $3) values ($1, $2::jsonb, $3, $4)
returning id, guid, created_at, updated_at returning id, guid, created_at, updated_at
`, thought.Content, metadata, thought.ProjectID) `, thought.Content, metadata, thought.ProjectID, tenantKeyPtr(ctx))
created := thought created := thought
created.Embedding = nil created.Embedding = nil
@@ -68,6 +68,22 @@ func (db *DB) InsertThought(ctx context.Context, thought thoughttypes.Thought, e
return created, nil return created, nil
} }
func (db *DB) GetThoughtByWebhookIDempotencyKey(ctx context.Context, key string) (thoughttypes.Thought, error) {
row := db.pool.QueryRow(ctx, `
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
from thoughts
where metadata->'webhook'->>'idempotency_key' = $1
order by created_at desc
limit 1
`, strings.TrimSpace(key))
var model generatedmodels.ModelPublicThoughts
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
return thoughttypes.Thought{}, err
}
return thoughtFromModel(model)
}
func (db *DB) SearchThoughts(ctx context.Context, embedding []float32, embeddingModel string, threshold float64, limit int, filter map[string]any) ([]thoughttypes.SearchResult, error) { func (db *DB) SearchThoughts(ctx context.Context, embedding []float32, embeddingModel string, threshold float64, limit int, filter map[string]any) ([]thoughttypes.SearchResult, error) {
filterJSON, err := json.Marshal(filter) filterJSON, err := json.Marshal(filter)
if err != nil { if err != nil {
@@ -107,6 +123,7 @@ func (db *DB) ListThoughts(ctx context.Context, filter thoughttypes.ListFilter)
args := make([]any, 0, 6) args := make([]any, 0, 6)
conditions := []string{} conditions := []string{}
addTenantCondition(ctx, &args, &conditions, "tenant_key")
if !filter.IncludeArchived { if !filter.IncludeArchived {
conditions = append(conditions, "archived_at is null") conditions = append(conditions, "archived_at is null")
} }
@@ -170,11 +187,14 @@ func (db *DB) ListThoughts(ctx context.Context, filter thoughttypes.ListFilter)
func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) { func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) {
var total int var total int
if err := db.pool.QueryRow(ctx, `select count(*) from thoughts where archived_at is null`).Scan(&total); err != nil { statsArgs := []any{}
statsConditions := []string{"archived_at is null"}
addTenantCondition(ctx, &statsArgs, &statsConditions, "tenant_key")
if err := db.pool.QueryRow(ctx, `select count(*) from thoughts where `+strings.Join(statsConditions, " and "), statsArgs...).Scan(&total); err != nil {
return thoughttypes.ThoughtStats{}, fmt.Errorf("count thoughts: %w", err) return thoughttypes.ThoughtStats{}, fmt.Errorf("count thoughts: %w", err)
} }
rows, err := db.pool.Query(ctx, `select metadata from thoughts where archived_at is null`) rows, err := db.pool.Query(ctx, `select metadata from thoughts where `+strings.Join(statsConditions, " and "), statsArgs...)
if err != nil { if err != nil {
return thoughttypes.ThoughtStats{}, fmt.Errorf("query stats metadata: %w", err) return thoughttypes.ThoughtStats{}, fmt.Errorf("query stats metadata: %w", err)
} }
@@ -217,11 +237,11 @@ func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) {
} }
func (db *DB) GetThought(ctx context.Context, id uuid.UUID) (thoughttypes.Thought, error) { func (db *DB) GetThought(ctx context.Context, id uuid.UUID) (thoughttypes.Thought, error) {
args := []any{id}
row := db.pool.QueryRow(ctx, ` row := db.pool.QueryRow(ctx, `
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
from thoughts from thoughts
where guid = $1 where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
`, id)
var model generatedmodels.ModelPublicThoughts var model generatedmodels.ModelPublicThoughts
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil { if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
@@ -240,11 +260,11 @@ func (db *DB) GetThought(ctx context.Context, id uuid.UUID) (thoughttypes.Though
} }
func (db *DB) GetThoughtByID(ctx context.Context, id int64) (thoughttypes.Thought, error) { func (db *DB) GetThoughtByID(ctx context.Context, id int64) (thoughttypes.Thought, error) {
args := []any{id}
row := db.pool.QueryRow(ctx, ` row := db.pool.QueryRow(ctx, `
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
from thoughts from thoughts
where id = $1 where id = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
`, id)
var model generatedmodels.ModelPublicThoughts var model generatedmodels.ModelPublicThoughts
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil { if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
@@ -276,14 +296,14 @@ func (db *DB) UpdateThought(ctx context.Context, id uuid.UUID, content string, e
_ = tx.Rollback(ctx) _ = tx.Rollback(ctx)
}() }()
args := []any{id, content, metadataBytes, projectID}
tag, err := tx.Exec(ctx, ` tag, err := tx.Exec(ctx, `
update thoughts update thoughts
set content = $2, set content = $2,
metadata = $3::jsonb, metadata = $3::jsonb,
project_id = $4, project_id = $4,
updated_at = now() updated_at = now()
where guid = $1 where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
`, id, content, metadataBytes, projectID)
if err != nil { if err != nil {
return thoughttypes.Thought{}, fmt.Errorf("update thought: %w", err) return thoughttypes.Thought{}, fmt.Errorf("update thought: %w", err)
} }
@@ -317,12 +337,12 @@ func (db *DB) UpdateThoughtMetadata(ctx context.Context, id int64, metadata thou
return thoughttypes.Thought{}, fmt.Errorf("marshal updated metadata: %w", err) return thoughttypes.Thought{}, fmt.Errorf("marshal updated metadata: %w", err)
} }
args := []any{id, metadataBytes}
tag, err := db.pool.Exec(ctx, ` tag, err := db.pool.Exec(ctx, `
update thoughts update thoughts
set metadata = $2::jsonb, set metadata = $2::jsonb,
updated_at = now() updated_at = now()
where id = $1 where id = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
`, id, metadataBytes)
if err != nil { if err != nil {
return thoughttypes.Thought{}, fmt.Errorf("update thought metadata: %w", err) return thoughttypes.Thought{}, fmt.Errorf("update thought metadata: %w", err)
} }
@@ -334,7 +354,8 @@ func (db *DB) UpdateThoughtMetadata(ctx context.Context, id int64, metadata thou
} }
func (db *DB) DeleteThought(ctx context.Context, id uuid.UUID) error { func (db *DB) DeleteThought(ctx context.Context, id uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `delete from thoughts where guid = $1`, id) args := []any{id}
tag, err := db.pool.Exec(ctx, `delete from thoughts where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
if err != nil { if err != nil {
return fmt.Errorf("delete thought: %w", err) return fmt.Errorf("delete thought: %w", err)
} }
@@ -345,7 +366,8 @@ func (db *DB) DeleteThought(ctx context.Context, id uuid.UUID) error {
} }
func (db *DB) ArchiveThought(ctx context.Context, id uuid.UUID) error { func (db *DB) ArchiveThought(ctx context.Context, id uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `update thoughts set archived_at = now(), updated_at = now() where guid = $1`, id) args := []any{id}
tag, err := db.pool.Exec(ctx, `update thoughts set archived_at = now(), updated_at = now() where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
if err != nil { if err != nil {
return fmt.Errorf("archive thought: %w", err) return fmt.Errorf("archive thought: %w", err)
} }
@@ -424,6 +446,7 @@ func (db *DB) SearchSimilarThoughts(ctx context.Context, embedding []float32, em
"1 - (e.embedding <=> $1) > $2", "1 - (e.embedding <=> $1) > $2",
"e.model = $3", "e.model = $3",
} }
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
if projectID != nil { if projectID != nil {
args = append(args, *projectID) args = append(args, *projectID)
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args))) conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
@@ -472,6 +495,7 @@ func (db *DB) HasEmbeddingsForModel(ctx context.Context, model string, projectID
"e.model = $1", "e.model = $1",
"t.archived_at is null", "t.archived_at is null",
} }
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
if projectID != nil { if projectID != nil {
args = append(args, *projectID) args = append(args, *projectID)
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args))) conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
@@ -490,6 +514,7 @@ func (db *DB) HasEmbeddingsForModel(ctx context.Context, model string, projectID
func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) { func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
args := []any{model} args := []any{model}
conditions := []string{"e.id is null"} conditions := []string{"e.id is null"}
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
if !includeArchived { if !includeArchived {
conditions = append(conditions, "t.archived_at is null") conditions = append(conditions, "t.archived_at is null")
@@ -539,6 +564,7 @@ func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, li
func (db *DB) ListThoughtsForMetadataReparse(ctx context.Context, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) { func (db *DB) ListThoughtsForMetadataReparse(ctx context.Context, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
args := make([]any, 0, 3) args := make([]any, 0, 3)
conditions := make([]string, 0, 4) conditions := make([]string, 0, 4)
addTenantCondition(ctx, &args, &conditions, "tenant_key")
if !includeArchived { if !includeArchived {
conditions = append(conditions, "archived_at is null") conditions = append(conditions, "archived_at is null")
@@ -608,6 +634,7 @@ func (db *DB) SearchThoughtsText(ctx context.Context, query string, limit int, p
"t.archived_at is null", "t.archived_at is null",
"(to_tsvector('simple', t.content) || to_tsvector('simple', coalesce(p.name, ''))) @@ websearch_to_tsquery('simple', $1)", "(to_tsvector('simple', t.content) || to_tsvector('simple', coalesce(p.name, ''))) @@ websearch_to_tsquery('simple', $1)",
} }
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
if projectID != nil { if projectID != nil {
args = append(args, *projectID) args = append(args, *projectID)
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args))) conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
+30
View File
@@ -0,0 +1,30 @@
package tenancy
import (
"context"
"strings"
)
type contextKey string
const tenantKeyContextKey contextKey = "tenancy.tenant_key"
// WithTenantKey returns a context scoped to the authenticated tenant boundary.
// The tenant key is intentionally opaque; today it is the authenticated API key
// or OAuth client id, and callers should not interpret it as a human username.
func WithTenantKey(ctx context.Context, tenantKey string) context.Context {
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" {
return ctx
}
return context.WithValue(ctx, tenantKeyContextKey, tenantKey)
}
func KeyFromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
value, ok := ctx.Value(tenantKeyContextKey).(string)
value = strings.TrimSpace(value)
return value, ok && value != ""
}
+2 -1
View File
@@ -422,6 +422,7 @@ func (t *AgentPersonasTool) RemovePersonaPart(ctx context.Context, _ *mcp.CallTo
type AddPersonaSkillInput struct { type AddPersonaSkillInput struct {
PersonaID int64 `json:"persona_id" jsonschema:"persona id"` PersonaID int64 `json:"persona_id" jsonschema:"persona id"`
SkillID int64 `json:"skill_id" jsonschema:"agent skill id to link"` SkillID int64 `json:"skill_id" jsonschema:"agent skill id to link"`
Override bool `json:"override,omitempty" jsonschema:"replace a project skill with the same name during world-model assembly"`
} }
type AddPersonaSkillOutput struct { type AddPersonaSkillOutput struct {
@@ -430,7 +431,7 @@ type AddPersonaSkillOutput struct {
} }
func (t *AgentPersonasTool) AddPersonaSkill(ctx context.Context, _ *mcp.CallToolRequest, in AddPersonaSkillInput) (*mcp.CallToolResult, AddPersonaSkillOutput, error) { func (t *AgentPersonasTool) AddPersonaSkill(ctx context.Context, _ *mcp.CallToolRequest, in AddPersonaSkillInput) (*mcp.CallToolResult, AddPersonaSkillOutput, error) {
if err := t.store.AddPersonaSkill(ctx, in.PersonaID, in.SkillID); err != nil { if err := t.store.AddPersonaSkill(ctx, in.PersonaID, in.SkillID, in.Override); err != nil {
return nil, AddPersonaSkillOutput{}, err return nil, AddPersonaSkillOutput{}, err
} }
return nil, AddPersonaSkillOutput{PersonaID: in.PersonaID, SkillID: in.SkillID}, nil return nil, AddPersonaSkillOutput{PersonaID: in.PersonaID, SkillID: in.SkillID}, nil
+11 -7
View File
@@ -36,9 +36,10 @@ type ContextItem struct {
} }
type ProjectContextOutput struct { type ProjectContextOutput struct {
Project thoughttypes.Project `json:"project"` Project thoughttypes.Project `json:"project"`
Context string `json:"context"` Context string `json:"context"`
Items []ContextItem `json:"items"` Items []ContextItem `json:"items"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
} }
func NewContextTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *ContextTool { func NewContextTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *ContextTool {
@@ -70,12 +71,14 @@ func (t *ContextTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in P
}) })
} }
var retrievalMode string
query := strings.TrimSpace(in.Query) query := strings.TrimSpace(in.Query)
if query != "" { if query != "" {
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, &project.NumericID, nil) semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, &project.NumericID, nil)
if err != nil { if err != nil {
return nil, ProjectContextOutput{}, err return nil, ProjectContextOutput{}, err
} }
retrievalMode = mode
for _, result := range semantic { for _, result := range semantic {
key := fmt.Sprint(result.ID) key := fmt.Sprint(result.ID)
if _, ok := seen[key]; ok { if _, ok := seen[key]; ok {
@@ -100,8 +103,9 @@ func (t *ContextTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in P
_ = t.store.TouchProject(ctx, project.NumericID) _ = t.store.TouchProject(ctx, project.NumericID)
return nil, ProjectContextOutput{ return nil, ProjectContextOutput{
Project: *project, Project: *project,
Context: contextBlock, Context: contextBlock,
Items: items, Items: items,
RetrievalMode: retrievalMode,
}, nil }, nil
} }
+305
View File
@@ -0,0 +1,305 @@
package tools
import (
"context"
"regexp"
"sort"
"strings"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
const defaultDuplicateAuditLimit = 50
var duplicateWhitespace = regexp.MustCompile(`\s+`)
type DuplicateAuditTool struct {
store *store.DB
search config.SearchConfig
sessions *session.ActiveProjects
}
type DuplicateAuditInput struct {
Project string `json:"project,omitempty" jsonschema:"optional project name or id to scope thought duplicate scanning"`
Limit int `json:"limit,omitempty" jsonschema:"maximum candidate groups to return"`
ThoughtLimit int `json:"thought_limit,omitempty" jsonschema:"maximum thoughts to scan for duplicate content; defaults to search limit"`
IncludeArchived bool `json:"include_archived,omitempty" jsonschema:"include archived thoughts in the scan"`
}
type DuplicateAuditOutput struct {
Report DuplicateAuditReport `json:"report"`
}
type DuplicateAuditReport struct {
Summary DuplicateAuditSummary `json:"summary"`
ProjectCandidates []DuplicateProjectCandidate `json:"project_candidates"`
ThoughtCandidates []DuplicateThoughtCandidate `json:"thought_candidates"`
MetadataCandidates []MetadataCleanupCandidate `json:"metadata_candidates"`
RecommendedNextStep string `json:"recommended_next_step"`
}
type DuplicateAuditSummary struct {
DryRun bool `json:"dry_run"`
ScannedProjects int `json:"scanned_projects"`
ScannedThoughts int `json:"scanned_thoughts"`
ProjectCandidateGroups int `json:"project_candidate_groups"`
ThoughtCandidateGroups int `json:"thought_candidate_groups"`
MetadataCandidateGroups int `json:"metadata_candidate_groups"`
Truncated bool `json:"truncated"`
}
type DuplicateProjectCandidate struct {
MatchType string `json:"match_type"`
NormalizedValue string `json:"normalized_value"`
Confidence float64 `json:"confidence"`
RecommendedCanonicalID uuid.UUID `json:"recommended_canonical_id"`
Projects []thoughttypes.ProjectSummary `json:"projects"`
}
type DuplicateThoughtCandidate struct {
MatchType string `json:"match_type"`
NormalizedValue string `json:"normalized_value"`
Confidence float64 `json:"confidence"`
RecommendedCanonicalID uuid.UUID `json:"recommended_canonical_id"`
Thoughts []thoughttypes.Thought `json:"thoughts"`
}
type MetadataCleanupCandidate struct {
Field string `json:"field"`
NormalizedValue string `json:"normalized_value"`
Values []string `json:"values"`
Occurrences int `json:"occurrences"`
RecommendedValue string `json:"recommended_value"`
RecommendedAction string `json:"recommended_action"`
}
func NewDuplicateAuditTool(db *store.DB, search config.SearchConfig, sessions *session.ActiveProjects) *DuplicateAuditTool {
return &DuplicateAuditTool{store: db, search: search, sessions: sessions}
}
func (t *DuplicateAuditTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in DuplicateAuditInput) (*mcp.CallToolResult, DuplicateAuditOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil {
return nil, DuplicateAuditOutput{}, err
}
var projectID *int64
if project != nil {
projectID = &project.NumericID
}
projects, err := t.store.ListProjects(ctx)
if err != nil {
return nil, DuplicateAuditOutput{}, err
}
thoughtLimit := normalizeLimit(in.ThoughtLimit, t.search)
if in.ThoughtLimit <= 0 {
thoughtLimit = normalizeLimit(0, t.search)
}
thoughts, err := t.store.ListThoughts(ctx, thoughttypes.ListFilter{Limit: thoughtLimit, ProjectID: projectID, IncludeArchived: in.IncludeArchived})
if err != nil {
return nil, DuplicateAuditOutput{}, err
}
if project != nil {
_ = t.store.TouchProject(ctx, project.NumericID)
}
return nil, DuplicateAuditOutput{Report: buildDuplicateAuditReport(projects, thoughts, in)}, nil
}
func buildDuplicateAuditReport(projects []thoughttypes.ProjectSummary, thoughts []thoughttypes.Thought, in DuplicateAuditInput) DuplicateAuditReport {
limit := in.Limit
if limit <= 0 {
limit = defaultDuplicateAuditLimit
}
projectCandidates, projectTruncated := duplicateProjectCandidates(projects, limit)
thoughtCandidates, thoughtTruncated := duplicateThoughtCandidates(thoughts, limit)
metadataCandidates, metadataTruncated := metadataCleanupCandidates(thoughts, limit)
return DuplicateAuditReport{
Summary: DuplicateAuditSummary{
DryRun: true,
ScannedProjects: len(projects),
ScannedThoughts: len(thoughts),
ProjectCandidateGroups: len(projectCandidates),
ThoughtCandidateGroups: len(thoughtCandidates),
MetadataCandidateGroups: len(metadataCandidates),
Truncated: projectTruncated || thoughtTruncated || metadataTruncated,
},
ProjectCandidates: projectCandidates,
ThoughtCandidates: thoughtCandidates,
MetadataCandidates: metadataCandidates,
RecommendedNextStep: "Review candidates manually, then use explicit archive/update/merge tools; this audit performs no writes.",
}
}
func duplicateProjectCandidates(projects []thoughttypes.ProjectSummary, limit int) ([]DuplicateProjectCandidate, bool) {
groups := map[string][]thoughttypes.ProjectSummary{}
for _, project := range projects {
key := normalizeDuplicateText(project.Name)
if key != "" {
groups[key] = append(groups[key], project)
}
}
keys := sortedDuplicateKeys(groups)
out := make([]DuplicateProjectCandidate, 0, min(len(keys), limit))
for _, key := range keys {
items := groups[key]
sort.SliceStable(items, func(i, j int) bool {
if items[i].ThoughtCount != items[j].ThoughtCount {
return items[i].ThoughtCount > items[j].ThoughtCount
}
return items[i].CreatedAt.Before(items[j].CreatedAt)
})
matchType := "normalized_name"
if allProjectNamesExact(items) {
matchType = "exact_name"
}
out = append(out, DuplicateProjectCandidate{MatchType: matchType, NormalizedValue: key, Confidence: 1.0, RecommendedCanonicalID: items[0].ID, Projects: items})
if len(out) == limit {
return out, len(keys) > limit
}
}
return out, false
}
func duplicateThoughtCandidates(thoughts []thoughttypes.Thought, limit int) ([]DuplicateThoughtCandidate, bool) {
groups := map[string][]thoughttypes.Thought{}
for _, thought := range thoughts {
key := normalizeDuplicateText(thought.Content)
if key != "" {
groups[key] = append(groups[key], thought)
}
}
keys := sortedDuplicateKeys(groups)
out := make([]DuplicateThoughtCandidate, 0, min(len(keys), limit))
for _, key := range keys {
items := groups[key]
sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) })
matchType := "normalized_content"
if allThoughtContentExact(items) {
matchType = "exact_content"
}
out = append(out, DuplicateThoughtCandidate{MatchType: matchType, NormalizedValue: key, Confidence: 1.0, RecommendedCanonicalID: items[0].GUID, Thoughts: items})
if len(out) == limit {
return out, len(keys) > limit
}
}
return out, false
}
func metadataCleanupCandidates(thoughts []thoughttypes.Thought, limit int) ([]MetadataCleanupCandidate, bool) {
groups := map[string]map[string]int{}
add := func(field, value string) {
trimmed := strings.TrimSpace(value)
key := normalizeDuplicateText(trimmed)
if key == "" || trimmed == "" {
return
}
bucketKey := field + "\x00" + key
if groups[bucketKey] == nil {
groups[bucketKey] = map[string]int{}
}
groups[bucketKey][trimmed]++
}
for _, thought := range thoughts {
add("type", thought.Metadata.Type)
add("source", thought.Metadata.Source)
for _, topic := range thought.Metadata.Topics {
add("topics", topic)
}
for _, person := range thought.Metadata.People {
add("people", person)
}
}
keys := make([]string, 0, len(groups))
for key, values := range groups {
if len(values) > 1 {
keys = append(keys, key)
}
}
sort.Strings(keys)
out := make([]MetadataCleanupCandidate, 0, min(len(keys), limit))
for _, key := range keys {
parts := strings.SplitN(key, "\x00", 2)
values, occurrences, recommended := valuesByCount(groups[key])
out = append(out, MetadataCleanupCandidate{
Field: parts[0],
NormalizedValue: parts[1],
Values: values,
Occurrences: occurrences,
RecommendedValue: recommended,
RecommendedAction: "preview bulk metadata remap before applying; no changes made by audit",
})
if len(out) == limit {
return out, len(keys) > limit
}
}
return out, false
}
func sortedDuplicateKeys[T any](groups map[string][]T) []string {
keys := make([]string, 0, len(groups))
for key, items := range groups {
if len(items) > 1 {
keys = append(keys, key)
}
}
sort.Strings(keys)
return keys
}
func valuesByCount(counts map[string]int) ([]string, int, string) {
values := make([]string, 0, len(counts))
occurrences := 0
for value, count := range counts {
values = append(values, value)
occurrences += count
}
sort.Slice(values, func(i, j int) bool {
if counts[values[i]] != counts[values[j]] {
return counts[values[i]] > counts[values[j]]
}
return values[i] < values[j]
})
return values, occurrences, values[0]
}
func normalizeDuplicateText(value string) string {
return duplicateWhitespace.ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), " ")
}
func allProjectNamesExact(projects []thoughttypes.ProjectSummary) bool {
if len(projects) == 0 {
return false
}
first := strings.TrimSpace(projects[0].Name)
for _, project := range projects[1:] {
if strings.TrimSpace(project.Name) != first {
return false
}
}
return true
}
func allThoughtContentExact(thoughts []thoughttypes.Thought) bool {
if len(thoughts) == 0 {
return false
}
first := strings.TrimSpace(thoughts[0].Content)
for _, thought := range thoughts[1:] {
if strings.TrimSpace(thought.Content) != first {
return false
}
}
return true
}
+76
View File
@@ -0,0 +1,76 @@
package tools
import (
"testing"
"time"
"github.com/google/uuid"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
func TestBuildDuplicateAuditGroupsProjectsAndThoughtsSafely(t *testing.T) {
now := time.Date(2026, 4, 12, 20, 5, 0, 0, time.UTC)
projects := []thoughttypes.ProjectSummary{
{Project: thoughttypes.Project{ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), NumericID: 1, Name: "AMCS", CreatedAt: now}, ThoughtCount: 3},
{Project: thoughttypes.Project{ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), NumericID: 2, Name: "amcs ", CreatedAt: now.Add(time.Hour)}, ThoughtCount: 1},
{Project: thoughttypes.Project{ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"), NumericID: 3, Name: "other", CreatedAt: now}, ThoughtCount: 1},
}
thoughts := []thoughttypes.Thought{
{ID: 10, GUID: uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), Content: "Same text", Metadata: thoughttypes.ThoughtMetadata{Type: "Note", Topics: []string{"Go", "go"}, People: []string{"Alice"}}, CreatedAt: now},
{ID: 11, GUID: uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Content: " same text ", Metadata: thoughttypes.ThoughtMetadata{Type: "note", Topics: []string{"go"}, People: []string{"alice"}}, CreatedAt: now.Add(time.Hour)},
{ID: 12, GUID: uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc"), Content: "unique", Metadata: thoughttypes.ThoughtMetadata{Type: "Task", Topics: []string{"Tasks"}}, CreatedAt: now},
}
report := buildDuplicateAuditReport(projects, thoughts, DuplicateAuditInput{})
if len(report.ProjectCandidates) != 1 {
t.Fatalf("project candidates = %d, want 1: %#v", len(report.ProjectCandidates), report.ProjectCandidates)
}
projectCandidate := report.ProjectCandidates[0]
if projectCandidate.MatchType != "normalized_name" || projectCandidate.NormalizedValue != "amcs" || len(projectCandidate.Projects) != 2 {
t.Fatalf("project candidate mismatch: %#v", projectCandidate)
}
if projectCandidate.RecommendedCanonicalID != projects[0].ID {
t.Fatalf("recommended project = %s, want %s", projectCandidate.RecommendedCanonicalID, projects[0].ID)
}
if len(report.ThoughtCandidates) != 1 {
t.Fatalf("thought candidates = %d, want 1: %#v", len(report.ThoughtCandidates), report.ThoughtCandidates)
}
thoughtCandidate := report.ThoughtCandidates[0]
if thoughtCandidate.MatchType != "normalized_content" || thoughtCandidate.NormalizedValue != "same text" || len(thoughtCandidate.Thoughts) != 2 {
t.Fatalf("thought candidate mismatch: %#v", thoughtCandidate)
}
if thoughtCandidate.RecommendedCanonicalID != thoughts[0].GUID {
t.Fatalf("recommended thought = %s, want %s", thoughtCandidate.RecommendedCanonicalID, thoughts[0].GUID)
}
if len(report.MetadataCandidates) != 3 {
t.Fatalf("metadata candidates = %d, want 3: %#v", len(report.MetadataCandidates), report.MetadataCandidates)
}
if report.Summary.ProjectCandidateGroups != 1 || report.Summary.ThoughtCandidateGroups != 1 || report.Summary.MetadataCandidateGroups != 3 {
t.Fatalf("summary mismatch: %#v", report.Summary)
}
if report.Summary.DryRun != true {
t.Fatalf("dry run = false, want true")
}
}
func TestBuildDuplicateAuditRespectsLimit(t *testing.T) {
projects := []thoughttypes.ProjectSummary{
{Project: thoughttypes.Project{ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Name: "Alpha"}},
{Project: thoughttypes.Project{ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), Name: "alpha"}},
{Project: thoughttypes.Project{ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"), Name: "Beta"}},
{Project: thoughttypes.Project{ID: uuid.MustParse("44444444-4444-4444-4444-444444444444"), Name: "beta"}},
}
report := buildDuplicateAuditReport(projects, nil, DuplicateAuditInput{Limit: 1})
if len(report.ProjectCandidates) != 1 {
t.Fatalf("project candidates = %d, want 1", len(report.ProjectCandidates))
}
if report.Summary.Truncated != true {
t.Fatalf("truncated = false, want true")
}
}
+6 -3
View File
@@ -45,7 +45,8 @@ type RelatedThought struct {
} }
type RelatedOutput struct { type RelatedOutput struct {
Related []RelatedThought `json:"related"` Related []RelatedThought `json:"related"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
} }
func NewLinksTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig) *LinksTool { func NewLinksTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig) *LinksTool {
@@ -117,11 +118,13 @@ func (t *LinksTool) Related(ctx context.Context, _ *mcp.CallToolRequest, in Rela
includeSemantic = *in.IncludeSemantic includeSemantic = *in.IncludeSemantic
} }
var retrievalMode string
if includeSemantic { if includeSemantic {
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, thought.Content, t.search.DefaultLimit, t.search.DefaultThreshold, thought.ProjectID, &thought.GUID) semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, thought.Content, t.search.DefaultLimit, t.search.DefaultThreshold, thought.ProjectID, &thought.GUID)
if err != nil { if err != nil {
return nil, RelatedOutput{}, err return nil, RelatedOutput{}, err
} }
retrievalMode = mode
for _, item := range semantic { for _, item := range semantic {
key := fmt.Sprint(item.ID) key := fmt.Sprint(item.ID)
if _, ok := seen[key]; ok { if _, ok := seen[key]; ok {
@@ -138,5 +141,5 @@ func (t *LinksTool) Related(ctx context.Context, _ *mcp.CallToolRequest, in Rela
} }
} }
return nil, RelatedOutput{Related: related}, nil return nil, RelatedOutput{Related: related, RetrievalMode: retrievalMode}, nil
} }
+82
View File
@@ -0,0 +1,82 @@
package tools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
type ProjectPersonasTool struct {
store *store.DB
sessions *session.ActiveProjects
}
func NewProjectPersonasTool(db *store.DB, sessions *session.ActiveProjects) *ProjectPersonasTool {
return &ProjectPersonasTool{store: db, sessions: sessions}
}
type ProjectPersonaInput struct {
Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"`
PersonaID int64 `json:"persona_id" jsonschema:"persona id"`
IsDefault bool `json:"is_default,omitempty" jsonschema:"make this the default persona for the project"`
}
type ProjectPersonaOutput struct {
ProjectID int64 `json:"project_id"`
PersonaID int64 `json:"persona_id"`
}
type ListProjectPersonasInput struct {
Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"`
}
type ListProjectPersonasOutput struct {
ProjectID int64 `json:"project_id"`
Personas []ext.ProjectPersona `json:"personas"`
}
func (t *ProjectPersonasTool) Add(ctx context.Context, req *mcp.CallToolRequest, in ProjectPersonaInput) (*mcp.CallToolResult, ProjectPersonaOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true)
if err != nil {
return nil, ProjectPersonaOutput{}, err
}
if err := t.store.AddProjectPersona(ctx, project.NumericID, in.PersonaID, in.IsDefault); err != nil {
return nil, ProjectPersonaOutput{}, err
}
return nil, ProjectPersonaOutput{ProjectID: project.NumericID, PersonaID: in.PersonaID}, nil
}
func (t *ProjectPersonasTool) Remove(ctx context.Context, req *mcp.CallToolRequest, in ProjectPersonaInput) (*mcp.CallToolResult, ProjectPersonaOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true)
if err != nil {
return nil, ProjectPersonaOutput{}, err
}
if err := t.store.RemoveProjectPersona(ctx, project.NumericID, in.PersonaID); err != nil {
return nil, ProjectPersonaOutput{}, err
}
return nil, ProjectPersonaOutput{ProjectID: project.NumericID, PersonaID: in.PersonaID}, nil
}
func (t *ProjectPersonasTool) SetDefault(ctx context.Context, req *mcp.CallToolRequest, in ProjectPersonaInput) (*mcp.CallToolResult, ProjectPersonaOutput, error) {
in.IsDefault = true
return t.Add(ctx, req, in)
}
func (t *ProjectPersonasTool) List(ctx context.Context, req *mcp.CallToolRequest, in ListProjectPersonasInput) (*mcp.CallToolResult, ListProjectPersonasOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true)
if err != nil {
return nil, ListProjectPersonasOutput{}, err
}
personas, err := t.store.ListProjectPersonas(ctx, project.NumericID)
if err != nil {
return nil, ListProjectPersonasOutput{}, err
}
if personas == nil {
personas = []ext.ProjectPersona{}
}
return nil, ListProjectPersonasOutput{ProjectID: project.NumericID, Personas: personas}, nil
}
+7 -5
View File
@@ -27,8 +27,9 @@ type RecallInput struct {
} }
type RecallOutput struct { type RecallOutput struct {
Context string `json:"context"` Context string `json:"context"`
Items []ContextItem `json:"items"` Items []ContextItem `json:"items"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
} }
func NewRecallTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *RecallTool { func NewRecallTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *RecallTool {
@@ -53,7 +54,7 @@ func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Re
projectID = &project.NumericID projectID = &project.NumericID
} }
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil) semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
if err != nil { if err != nil {
return nil, RecallOutput{}, err return nil, RecallOutput{}, err
} }
@@ -100,7 +101,8 @@ func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Re
} }
return nil, RecallOutput{ return nil, RecallOutput{
Context: formatContextBlock(header, lines), Context: formatContextBlock(header, lines),
Items: items, Items: items,
RetrievalMode: mode,
}, nil }, nil
} }
+13 -5
View File
@@ -11,10 +11,16 @@ import (
thoughttypes "git.warky.dev/wdevs/amcs/internal/types" thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
) )
const (
RetrievalModeSemantic = "semantic"
RetrievalModeText = "text"
)
// semanticSearch runs vector similarity search if embeddings exist for the // semanticSearch runs vector similarity search if embeddings exist for the
// primary embedding model in the given scope, otherwise falls back to Postgres // primary embedding model in the given scope, otherwise falls back to Postgres
// full-text search. Search always uses the primary model so query vectors // full-text search. Search always uses the primary model so query vectors
// match rows stored under the primary model name. // match rows stored under the primary model name.
// It returns the results and the retrieval mode used ("semantic" or "text").
func semanticSearch( func semanticSearch(
ctx context.Context, ctx context.Context,
db *store.DB, db *store.DB,
@@ -25,20 +31,22 @@ func semanticSearch(
threshold float64, threshold float64,
projectID *int64, projectID *int64,
excludeID *uuid.UUID, excludeID *uuid.UUID,
) ([]thoughttypes.SearchResult, error) { ) ([]thoughttypes.SearchResult, string, error) {
model := embeddings.PrimaryModel() model := embeddings.PrimaryModel()
hasEmbeddings, err := db.HasEmbeddingsForModel(ctx, model, projectID) hasEmbeddings, err := db.HasEmbeddingsForModel(ctx, model, projectID)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
if hasEmbeddings { if hasEmbeddings {
embedding, err := embeddings.EmbedPrimary(ctx, query) embedding, err := embeddings.EmbedPrimary(ctx, query)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
return db.SearchSimilarThoughts(ctx, embedding, model, threshold, limit, projectID, excludeID) results, err := db.SearchSimilarThoughts(ctx, embedding, model, threshold, limit, projectID, excludeID)
return results, RetrievalModeSemantic, err
} }
return db.SearchThoughtsText(ctx, query, limit, projectID, excludeID) results, err := db.SearchThoughtsText(ctx, query, limit, projectID, excludeID)
return results, RetrievalModeText, err
} }
+60
View File
@@ -0,0 +1,60 @@
package tools
import "testing"
func TestRetrievalModeConstants(t *testing.T) {
if RetrievalModeSemantic != "semantic" {
t.Fatalf("RetrievalModeSemantic = %q, want %q", RetrievalModeSemantic, "semantic")
}
if RetrievalModeText != "text" {
t.Fatalf("RetrievalModeText = %q, want %q", RetrievalModeText, "text")
}
}
func TestSearchOutputIncludesRetrievalMode(t *testing.T) {
out := SearchOutput{RetrievalMode: RetrievalModeSemantic}
if out.RetrievalMode != RetrievalModeSemantic {
t.Fatalf("SearchOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeSemantic)
}
}
func TestRelatedOutputIncludesRetrievalMode(t *testing.T) {
out := RelatedOutput{RetrievalMode: RetrievalModeText}
if out.RetrievalMode != RetrievalModeText {
t.Fatalf("RelatedOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeText)
}
// retrieval_mode is omitempty — empty string means semantic search was skipped
out2 := RelatedOutput{}
if out2.RetrievalMode != "" {
t.Fatalf("RelatedOutput.RetrievalMode = %q, want empty when include_semantic is false", out2.RetrievalMode)
}
}
func TestSummarizeOutputIncludesRetrievalModeOnlyWhenQueryUsed(t *testing.T) {
withQuery := SummarizeOutput{Summary: "s", Count: 1, RetrievalMode: RetrievalModeSemantic}
if withQuery.RetrievalMode != RetrievalModeSemantic {
t.Fatalf("SummarizeOutput.RetrievalMode = %q, want %q", withQuery.RetrievalMode, RetrievalModeSemantic)
}
withoutQuery := SummarizeOutput{Summary: "s", Count: 1}
if withoutQuery.RetrievalMode != "" {
t.Fatalf("SummarizeOutput.RetrievalMode = %q, want empty when no query", withoutQuery.RetrievalMode)
}
}
func TestProjectContextOutputIncludesRetrievalModeOnlyWhenQueryUsed(t *testing.T) {
withQuery := ProjectContextOutput{RetrievalMode: RetrievalModeText}
if withQuery.RetrievalMode != RetrievalModeText {
t.Fatalf("ProjectContextOutput.RetrievalMode = %q, want %q", withQuery.RetrievalMode, RetrievalModeText)
}
withoutQuery := ProjectContextOutput{}
if withoutQuery.RetrievalMode != "" {
t.Fatalf("ProjectContextOutput.RetrievalMode = %q, want empty when no query", withoutQuery.RetrievalMode)
}
}
func TestRecallOutputIncludesRetrievalMode(t *testing.T) {
out := RecallOutput{RetrievalMode: RetrievalModeSemantic}
if out.RetrievalMode != RetrievalModeSemantic {
t.Fatalf("RecallOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeSemantic)
}
}
+4 -3
View File
@@ -28,7 +28,8 @@ type SearchInput struct {
} }
type SearchOutput struct { type SearchOutput struct {
Results []thoughttypes.SearchResult `json:"results"` Results []thoughttypes.SearchResult `json:"results"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
} }
func NewSearchTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SearchTool { func NewSearchTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SearchTool {
@@ -55,10 +56,10 @@ func (t *SearchTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Se
_ = t.store.TouchProject(ctx, project.NumericID) _ = t.store.TouchProject(ctx, project.NumericID)
} }
results, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, threshold, projectID, nil) results, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, threshold, projectID, nil)
if err != nil { if err != nil {
return nil, SearchOutput{}, err return nil, SearchOutput{}, err
} }
return nil, SearchOutput{Results: results}, nil return nil, SearchOutput{Results: results, RetrievalMode: mode}, nil
} }
+4 -3
View File
@@ -259,8 +259,9 @@ func (t *SkillsTool) GetGuardrail(ctx context.Context, _ *mcp.CallToolRequest, i
// add_project_skill // add_project_skill
type AddProjectSkillInput struct { type AddProjectSkillInput struct {
Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"` Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"`
SkillID int64 `json:"skill_id" jsonschema:"skill id to link"` SkillID int64 `json:"skill_id" jsonschema:"skill id to link"`
Override bool `json:"override,omitempty" jsonschema:"replace a lower-precedence skill with the same name during world-model assembly"`
} }
type AddProjectSkillOutput struct { type AddProjectSkillOutput struct {
@@ -273,7 +274,7 @@ func (t *SkillsTool) AddProjectSkill(ctx context.Context, req *mcp.CallToolReque
if err != nil { if err != nil {
return nil, AddProjectSkillOutput{}, err return nil, AddProjectSkillOutput{}, err
} }
if err := t.store.AddProjectSkill(ctx, project.NumericID, in.SkillID); err != nil { if err := t.store.AddProjectSkill(ctx, project.NumericID, in.SkillID, in.Override); err != nil {
return nil, AddProjectSkillOutput{}, err return nil, AddProjectSkillOutput{}, err
} }
return nil, AddProjectSkillOutput{ProjectID: project.NumericID, SkillID: in.SkillID}, nil return nil, AddProjectSkillOutput{ProjectID: project.NumericID, SkillID: in.SkillID}, nil
+7 -4
View File
@@ -28,8 +28,9 @@ type SummarizeInput struct {
} }
type SummarizeOutput struct { type SummarizeOutput struct {
Summary string `json:"summary"` Summary string `json:"summary"`
Count int `json:"count"` Count int `json:"count"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
} }
func NewSummarizeTool(db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SummarizeTool { func NewSummarizeTool(db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SummarizeTool {
@@ -47,15 +48,17 @@ func (t *SummarizeTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in
lines := make([]string, 0, limit) lines := make([]string, 0, limit)
count := 0 count := 0
var retrievalMode string
if query != "" { if query != "" {
var projectID *int64 var projectID *int64
if project != nil { if project != nil {
projectID = &project.NumericID projectID = &project.NumericID
} }
results, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil) results, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
if err != nil { if err != nil {
return nil, SummarizeOutput{}, err return nil, SummarizeOutput{}, err
} }
retrievalMode = mode
for i, result := range results { for i, result := range results {
lines = append(lines, thoughtContextLine(i, result.Content, result.Metadata, result.Similarity)) lines = append(lines, thoughtContextLine(i, result.Content, result.Metadata, result.Similarity))
} }
@@ -85,5 +88,5 @@ func (t *SummarizeTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in
_ = t.store.TouchProject(ctx, project.NumericID) _ = t.store.TouchProject(ctx, project.NumericID)
} }
return nil, SummarizeOutput{Summary: summary, Count: count}, nil return nil, SummarizeOutput{Summary: summary, Count: count, RetrievalMode: retrievalMode}, nil
} }
+152
View File
@@ -0,0 +1,152 @@
package tools
import (
"context"
"strconv"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
type ThoughtLearningLinksTool struct {
store *store.DB
}
type LinkThoughtLearningInput struct {
ThoughtID string `json:"thought_id" jsonschema:"UUID of the thought to link"`
LearningID int64 `json:"learning_id" jsonschema:"numeric id of the learning to link"`
Relation string `json:"relation,omitempty" jsonschema:"relationship label, e.g. source, derived_from, related; defaults to source"`
}
type LinkThoughtLearningOutput struct {
Linked bool `json:"linked"`
}
type UnlinkThoughtLearningInput struct {
ThoughtID string `json:"thought_id" jsonschema:"UUID of the thought"`
LearningID int64 `json:"learning_id" jsonschema:"numeric id of the learning"`
}
type UnlinkThoughtLearningOutput struct {
Unlinked bool `json:"unlinked"`
}
type GetThoughtLearningsInput struct {
ThoughtID string `json:"thought_id" jsonschema:"UUID of the thought"`
}
type GetThoughtLearningsOutput struct {
Learnings []thoughttypes.LinkedLearning `json:"learnings"`
}
type GetLearningThoughtsInput struct {
LearningID int64 `json:"learning_id" jsonschema:"numeric id of the learning"`
}
type GetLearningThoughtsOutput struct {
Thoughts []thoughttypes.LinkedLearningThought `json:"thoughts"`
}
func NewThoughtLearningLinksTool(db *store.DB) *ThoughtLearningLinksTool {
return &ThoughtLearningLinksTool{store: db}
}
func (t *ThoughtLearningLinksTool) Link(ctx context.Context, _ *mcp.CallToolRequest, in LinkThoughtLearningInput) (*mcp.CallToolResult, LinkThoughtLearningOutput, error) {
if err := t.ensureConfigured(); err != nil {
return nil, LinkThoughtLearningOutput{}, err
}
thoughtGUID, err := parseUUID(in.ThoughtID)
if err != nil {
return nil, LinkThoughtLearningOutput{}, err
}
if in.LearningID <= 0 {
return nil, LinkThoughtLearningOutput{}, errRequiredField("learning_id")
}
relation := strings.TrimSpace(in.Relation)
if relation == "" {
relation = "source"
}
if _, err := t.store.GetThought(ctx, thoughtGUID); err != nil {
return nil, LinkThoughtLearningOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID)
}
if _, err := t.store.GetLearning(ctx, in.LearningID); err != nil {
return nil, LinkThoughtLearningOutput{}, errEntityNotFound("learning", "learning_id", strconv.FormatInt(in.LearningID, 10))
}
if err := t.store.LinkThoughtLearning(ctx, thoughtGUID, in.LearningID, relation); err != nil {
return nil, LinkThoughtLearningOutput{}, err
}
return nil, LinkThoughtLearningOutput{Linked: true}, nil
}
func (t *ThoughtLearningLinksTool) Unlink(ctx context.Context, _ *mcp.CallToolRequest, in UnlinkThoughtLearningInput) (*mcp.CallToolResult, UnlinkThoughtLearningOutput, error) {
if err := t.ensureConfigured(); err != nil {
return nil, UnlinkThoughtLearningOutput{}, err
}
thoughtGUID, err := parseUUID(in.ThoughtID)
if err != nil {
return nil, UnlinkThoughtLearningOutput{}, err
}
if in.LearningID <= 0 {
return nil, UnlinkThoughtLearningOutput{}, errRequiredField("learning_id")
}
if err := t.store.UnlinkThoughtLearning(ctx, thoughtGUID, in.LearningID); err != nil {
return nil, UnlinkThoughtLearningOutput{}, err
}
return nil, UnlinkThoughtLearningOutput{Unlinked: true}, nil
}
func (t *ThoughtLearningLinksTool) GetThoughtLearnings(ctx context.Context, _ *mcp.CallToolRequest, in GetThoughtLearningsInput) (*mcp.CallToolResult, GetThoughtLearningsOutput, error) {
if err := t.ensureConfigured(); err != nil {
return nil, GetThoughtLearningsOutput{}, err
}
thoughtGUID, err := parseUUID(in.ThoughtID)
if err != nil {
return nil, GetThoughtLearningsOutput{}, err
}
if _, err := t.store.GetThought(ctx, thoughtGUID); err != nil {
return nil, GetThoughtLearningsOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID)
}
learnings, err := t.store.GetLinkedLearnings(ctx, thoughtGUID)
if err != nil {
return nil, GetThoughtLearningsOutput{}, err
}
return nil, GetThoughtLearningsOutput{Learnings: learnings}, nil
}
func (t *ThoughtLearningLinksTool) GetLearningThoughts(ctx context.Context, _ *mcp.CallToolRequest, in GetLearningThoughtsInput) (*mcp.CallToolResult, GetLearningThoughtsOutput, error) {
if err := t.ensureConfigured(); err != nil {
return nil, GetLearningThoughtsOutput{}, err
}
if in.LearningID <= 0 {
return nil, GetLearningThoughtsOutput{}, errRequiredField("learning_id")
}
if _, err := t.store.GetLearning(ctx, in.LearningID); err != nil {
return nil, GetLearningThoughtsOutput{}, errEntityNotFound("learning", "learning_id", strconv.FormatInt(in.LearningID, 10))
}
thoughts, err := t.store.GetLinkedThoughtsForLearning(ctx, in.LearningID)
if err != nil {
return nil, GetLearningThoughtsOutput{}, err
}
return nil, GetLearningThoughtsOutput{Thoughts: thoughts}, nil
}
func (t *ThoughtLearningLinksTool) ensureConfigured() error {
if t == nil || t.store == nil {
return errInvalidInput("thought learning links tool is not configured")
}
return nil
}
+183
View File
@@ -0,0 +1,183 @@
package tools
import (
"context"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
type WorldModelTool struct {
store *store.DB
sessions *session.ActiveProjects
}
func NewWorldModelTool(db *store.DB, sessions *session.ActiveProjects) *WorldModelTool {
return &WorldModelTool{store: db, sessions: sessions}
}
type BootstrapWorldModelInput struct {
Project string `json:"project" jsonschema:"project name or id; must be explicit for deterministic startup"`
ContextLimit int `json:"context_limit,omitempty" jsonschema:"recent project thoughts to include (default 10, maximum 50)"`
}
type EffectiveSkill struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Content string `json:"content,omitempty"`
Tags []string `json:"tags"`
Source string `json:"source"`
Override bool `json:"override"`
}
type WorldModelContextItem struct {
ID int64 `json:"id"`
Content string `json:"content"`
Metadata ext.ThoughtMetadata `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
}
type BootstrapWorldModelOutput struct {
Version int `json:"version"`
GeneratedAt time.Time `json:"generated_at"`
Project ext.Project `json:"project"`
Skills []EffectiveSkill `json:"skills"`
Guardrails []ext.AgentGuardrail `json:"guardrails"`
Personas []ext.ProjectPersona `json:"personas"`
PersonaManifests []ext.PersonaManifest `json:"persona_manifests"`
DefaultPersona *ext.PersonaFull `json:"default_persona,omitempty"`
Context []WorldModelContextItem `json:"context"`
}
func (t *WorldModelTool) Bootstrap(ctx context.Context, req *mcp.CallToolRequest, in BootstrapWorldModelInput) (*mcp.CallToolResult, BootstrapWorldModelOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
if t.sessions != nil && req != nil && req.Session != nil {
t.sessions.Set(req.Session.ID(), project.ID)
}
projectSkills, err := t.store.ListProjectSkills(ctx, project.NumericID)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
guardrails, err := t.store.ListProjectGuardrails(ctx, project.NumericID)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
personas, err := t.store.ListProjectPersonas(ctx, project.NumericID)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
manifests := make([]ext.PersonaManifest, 0, len(personas))
for _, linked := range personas {
manifest, err := t.store.GetPersonaManifest(ctx, linked.Persona.Name)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
manifests = append(manifests, manifest)
}
var defaultPersona *ext.PersonaFull
for _, linked := range personas {
if !linked.IsDefault {
continue
}
loaded, err := t.store.GetPersona(ctx, linked.Persona.Name, true, nil)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
defaultPersona = &loaded
break
}
skills := mergeWorldModelSkills(projectSkills, defaultPersona)
guardrails = mergeWorldModelGuardrails(guardrails, defaultPersona)
limit := in.ContextLimit
if limit <= 0 {
limit = 10
}
if limit > 50 {
limit = 50
}
recent, err := t.store.RecentThoughts(ctx, &project.NumericID, limit, 0)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
contextItems := make([]WorldModelContextItem, 0, len(recent))
for _, thought := range recent {
contextItems = append(contextItems, WorldModelContextItem{ID: thought.ID, Content: thought.Content, Metadata: thought.Metadata, CreatedAt: thought.CreatedAt})
}
if guardrails == nil {
guardrails = []ext.AgentGuardrail{}
}
if personas == nil {
personas = []ext.ProjectPersona{}
}
_ = t.store.TouchProject(ctx, project.NumericID)
return nil, BootstrapWorldModelOutput{
Version: 1, GeneratedAt: time.Now().UTC(), Project: *project, Skills: skills,
Guardrails: guardrails, Personas: personas, PersonaManifests: manifests,
DefaultPersona: defaultPersona, Context: contextItems,
}, nil
}
func mergeWorldModelSkills(projectSkills []ext.AgentSkill, persona *ext.PersonaFull) []EffectiveSkill {
result := make([]EffectiveSkill, 0, len(projectSkills))
index := make(map[string]int, len(projectSkills))
for _, skill := range projectSkills {
key := strings.ToLower(skill.Name)
index[key] = len(result)
result = append(result, EffectiveSkill{ID: skill.ID, Name: skill.Name, Description: skill.Description, Content: skill.Content, Tags: skill.Tags, Source: "project", Override: skill.Override})
}
if persona == nil {
return result
}
for _, skill := range persona.Skills {
key := strings.ToLower(skill.Name)
entry := EffectiveSkill{ID: skill.ID, Name: skill.Name, Description: skill.Description, Content: skill.Content, Tags: skill.Tags, Source: "persona", Override: skill.Override}
if i, ok := index[key]; ok {
if skill.Override {
result[i] = entry
}
continue
}
index[key] = len(result)
result = append(result, entry)
}
return result
}
func mergeWorldModelGuardrails(project []ext.AgentGuardrail, persona *ext.PersonaFull) []ext.AgentGuardrail {
result := append([]ext.AgentGuardrail(nil), project...)
index := make(map[string]int, len(result))
for i, guardrail := range result {
index[strings.ToLower(guardrail.Name)] = i
}
if persona == nil {
return result
}
severity := map[string]int{"low": 1, "medium": 2, "high": 3, "critical": 4}
for _, guardrail := range persona.Guardrails {
key := strings.ToLower(guardrail.Name)
if i, ok := index[key]; ok {
if severity[guardrail.Severity] > severity[result[i].Severity] {
result[i].Severity = guardrail.Severity
result[i].Content = guardrail.Content
}
continue
}
index[key] = len(result)
result = append(result, ext.AgentGuardrail{ID: guardrail.ID, Name: guardrail.Name, Description: guardrail.Description, Content: guardrail.Content, Severity: guardrail.Severity, Tags: guardrail.Tags})
}
return result
}
+52
View File
@@ -0,0 +1,52 @@
package tools
import (
"testing"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
func TestMergeWorldModelSkills(t *testing.T) {
project := []ext.AgentSkill{
{ID: 1, Name: "go", Content: "project", Tags: []string{}},
{ID: 2, Name: "postgres", Content: "database", Tags: []string{}},
}
persona := &ext.PersonaFull{Skills: []ext.PersonaSkillEntry{
{ID: 1, Name: "go", Content: "ignored additive duplicate", Tags: []string{}},
{ID: 3, Name: "Postgres", Content: "persona override", Tags: []string{}, Override: true},
{ID: 4, Name: "writing", Content: "additive", Tags: []string{}},
}}
got := mergeWorldModelSkills(project, persona)
if len(got) != 3 {
t.Fatalf("len(skills) = %d, want 3", len(got))
}
if got[0].Content != "project" || got[0].Source != "project" {
t.Fatalf("additive duplicate replaced project skill: %#v", got[0])
}
if got[1].Content != "persona override" || got[1].Source != "persona" || !got[1].Override {
t.Fatalf("override did not replace project skill: %#v", got[1])
}
if got[2].Name != "writing" || got[2].Source != "persona" {
t.Fatalf("new additive persona skill missing: %#v", got[2])
}
}
func TestMergeWorldModelGuardrailsKeepsStrictest(t *testing.T) {
project := []ext.AgentGuardrail{{ID: 1, Name: "safe", Severity: "high", Content: "project"}}
persona := &ext.PersonaFull{Guardrails: []ext.PersonaGuardrailEntry{
{ID: 2, Name: "Safe", Severity: "low", Content: "weaker"},
{ID: 3, Name: "privacy", Severity: "critical", Content: "new"},
}}
got := mergeWorldModelGuardrails(project, persona)
if len(got) != 2 {
t.Fatalf("len(guardrails) = %d, want 2", len(got))
}
if got[0].Severity != "high" || got[0].Content != "project" {
t.Fatalf("persona weakened project guardrail: %#v", got[0])
}
if got[1].Name != "privacy" || got[1].Severity != "critical" {
t.Fatalf("new persona guardrail missing: %#v", got[1])
}
}
+19 -12
View File
@@ -81,17 +81,17 @@ type ArcStage struct {
// 2. active arc-stage parts // 2. active arc-stage parts
// 3. persona-linked parts (base) // 3. persona-linked parts (base)
type PersonaFull struct { type PersonaFull struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Body string `json:"body"` // summary or detail, depending on mode Body string `json:"body"` // summary or detail, depending on mode
CompiledSummary string `json:"compiled_summary,omitempty"` CompiledSummary string `json:"compiled_summary,omitempty"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Parts []AssembledPart `json:"parts"` Parts []AssembledPart `json:"parts"`
Skills []PersonaSkillEntry `json:"skills"` Skills []PersonaSkillEntry `json:"skills"`
Guardrails []PersonaGuardrailEntry `json:"guardrails"` Guardrails []PersonaGuardrailEntry `json:"guardrails"`
Traits []PersonaTraitEntry `json:"traits"` Traits []PersonaTraitEntry `json:"traits"`
Arc *PersonaArcState `json:"arc,omitempty"` Arc *PersonaArcState `json:"arc,omitempty"`
Detail bool `json:"detail"` // whether full detail was requested Detail bool `json:"detail"` // whether full detail was requested
} }
type AssembledPart struct { type AssembledPart struct {
@@ -109,13 +109,14 @@ type PersonaSkillEntry struct {
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Content string `json:"content,omitempty"` // only when detail=true Content string `json:"content,omitempty"` // only when detail=true
Tags []string `json:"tags"` Tags []string `json:"tags"`
Override bool `json:"override,omitempty"`
} }
type PersonaGuardrailEntry struct { type PersonaGuardrailEntry struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Content string `json:"content,omitempty"` // only when detail=true Content string `json:"content,omitempty"` // only when detail=true
Severity string `json:"severity,omitempty"` // only when detail=true Severity string `json:"severity,omitempty"` // only when detail=true
Tags []string `json:"tags"` Tags []string `json:"tags"`
} }
@@ -168,6 +169,12 @@ type ManifestSkill struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Override bool `json:"override,omitempty"`
}
type ProjectPersona struct {
Persona Persona `json:"persona"`
IsDefault bool `json:"is_default"`
} }
type ManifestGuardrail struct { type ManifestGuardrail struct {
+1
View File
@@ -229,6 +229,7 @@ type AgentSkill struct {
DomainTags []string `json:"domain_tags"` DomainTags []string `json:"domain_tags"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Override bool `json:"override,omitempty"`
} }
type AgentGuardrail struct { type AgentGuardrail struct {
+19
View File
@@ -39,3 +39,22 @@ type LinkedThought struct {
Direction string `json:"direction"` Direction string `json:"direction"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type ThoughtLearningLink struct {
ThoughtID int64 `json:"thought_id"`
LearningID int64 `json:"learning_id"`
Relation string `json:"relation"`
CreatedAt time.Time `json:"created_at"`
}
type LinkedLearning struct {
Learning Learning `json:"learning"`
Relation string `json:"relation"`
CreatedAt time.Time `json:"created_at"`
}
type LinkedLearningThought struct {
Thought Thought `json:"thought"`
Relation string `json:"relation"`
CreatedAt time.Time `json:"created_at"`
}
+21 -13
View File
@@ -14,12 +14,20 @@ type ThoughtMetadata struct {
Type string `json:"type"` Type string `json:"type"`
Source string `json:"source"` Source string `json:"source"`
Attachments []ThoughtAttachment `json:"attachments,omitempty"` Attachments []ThoughtAttachment `json:"attachments,omitempty"`
Webhook *WebhookMetadata `json:"webhook,omitempty"`
MetadataStatus string `json:"metadata_status,omitempty"` MetadataStatus string `json:"metadata_status,omitempty"`
MetadataUpdatedAt string `json:"metadata_updated_at,omitempty"` MetadataUpdatedAt string `json:"metadata_updated_at,omitempty"`
MetadataLastAttemptedAt string `json:"metadata_last_attempted_at,omitempty"` MetadataLastAttemptedAt string `json:"metadata_last_attempted_at,omitempty"`
MetadataError string `json:"metadata_error,omitempty"` MetadataError string `json:"metadata_error,omitempty"`
} }
type WebhookMetadata struct {
ReceivedAt string `json:"received_at"`
IDempotencyKey string `json:"idempotency_key,omitempty"`
ExternalID string `json:"external_id,omitempty"`
SourceMetadata map[string]any `json:"source_metadata,omitempty"`
}
type ThoughtAttachment struct { type ThoughtAttachment struct {
FileID uuid.UUID `json:"file_id"` FileID uuid.UUID `json:"file_id"`
Name string `json:"name"` Name string `json:"name"`
@@ -30,19 +38,19 @@ type ThoughtAttachment struct {
} }
type StoredFile struct { type StoredFile struct {
ID int64 `json:"id"` ID int64 `json:"id"`
GUID uuid.UUID `json:"guid"` GUID uuid.UUID `json:"guid"`
ThoughtID *int64 `json:"thought_id,omitempty"` ThoughtID *int64 `json:"thought_id,omitempty"`
ProjectID *int64 `json:"project_id,omitempty"` ProjectID *int64 `json:"project_id,omitempty"`
Name string `json:"name"` Name string `json:"name"`
MediaType string `json:"media_type"` MediaType string `json:"media_type"`
Kind string `json:"kind"` Kind string `json:"kind"`
Encoding string `json:"encoding"` Encoding string `json:"encoding"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"` SHA256 string `json:"sha256"`
Content []byte `json:"-"` Content []byte `json:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
type StoredFileFilter struct { type StoredFileFilter struct {
+3
View File
@@ -5,4 +5,7 @@ import _ "embed"
var ( var (
//go:embed memory.md //go:embed memory.md
MemoryInstructions []byte MemoryInstructions []byte
//go:embed world_model_intro.md
WorldModelIntro []byte
) )
+16 -3
View File
@@ -28,7 +28,20 @@ Use AMCS as memory with two scopes:
At the very start of any session with AMCS: At the very start of any session with AMCS:
1. Call `describe_tools` to get the full list of available tools with their categories and any notes you have previously annotated. Read the notes before using a tool — they contain accumulated gotchas, workflow patterns, and field-ordering requirements you have recorded from prior sessions. 1. Read the `amcs://world-model/intro` resource.
2. Identify the current project and call `bootstrap_world_model` with its explicit name or ID and the smallest useful `context_limit`.
3. Apply the returned skills and guardrails, but keep persona manifests and memory summaries as indexes. Load detailed content only when the current task requires it.
4. Call `describe_tools` only when tool discovery or saved usage notes are needed. Prefer a category filter instead of loading the full catalog.
## Token Budget and Progressive Loading
- Minimize startup and working context. Do not load data merely because it is available.
- Start with names, summaries, manifests, and metadata. Fetch full skill, guardrail, persona, trait, part, thought, plan, learning, chat, or file content only when it is relevant to the current task.
- Use the smallest practical `limit` or `context_limit`, narrow queries, and project/category filters. Increase them only when the initial result is insufficient.
- Do not preload every persona, skill, guardrail, plan, file, or historical memory. Follow references on demand.
- Avoid repeating unchanged world-model content in the conversation. Keep a concise working summary and refresh only when the project or task changes, or when stale context is suspected.
- Prefer `get_persona_manifest` and compiled summaries before `get_agent_persona(detail=true)`. Prefer `list_skills` metadata before `get_skill`, and file metadata before `load_file`.
- Guardrails and directly applicable skills are mandatory even under a tight token budget; token minimization must never omit an applicable constraint.
## Project Session Startup ## Project Session Startup
@@ -51,7 +64,7 @@ Do not abandon the project scope or retry without a project. The project simply
## Project Memory Rules ## Project Memory Rules
- Use project memory for code decisions, architecture, TODOs, debugging findings, and context specific to the current repo or workstream. - Use project memory for code decisions, architecture, TODOs, debugging findings, and context specific to the current repo or workstream.
- Before substantial work, always retrieve context with `get_project_context` or `recall_context` so prior decisions inform your approach. - Before substantial work, retrieve focused context with `get_project_context` or `recall_context` only when prior decisions may affect the task. Use a narrow query and small limit first.
- Save durable project facts with `capture_thought` after completing meaningful work. - Save durable project facts with `capture_thought` after completing meaningful work.
- Use structured learnings for curated, reusable lessons that should remain distinct from raw thought capture. - Use structured learnings for curated, reusable lessons that should remain distinct from raw thought capture.
- Use `save_file` or `upload_file` for project assets the memory should retain, such as screenshots, PDFs, audio notes, and other documents. - Use `save_file` or `upload_file` for project assets the memory should retain, such as screenshots, PDFs, audio notes, and other documents.
@@ -135,4 +148,4 @@ Notes are returned by `describe_tools` in future sessions. Annotate whenever you
## Short Operational Form ## Short Operational Form
At the start of every session, call `describe_tools` to read the full tool list and any accumulated usage notes. Use AMCS memory in project scope when the current work matches a known project; if no clear project matches, global notebook memory is allowed for non-project-specific information. At the start of every project session call `list_project_skills` and `list_project_guardrails` and apply what is returned; only create new skills or guardrails if none exist. If your MCP client does not preserve sessions across calls, pass `project` explicitly instead of relying on `set_active_project`. Store raw/durable notes with `capture_thought`, store curated durable lessons with `add_learning`, and track structured multi-step goals with `create_plan`. Use `get_plan` to load a plan's full context including dependencies, related plans, and linked skills/guardrails. Stamp `last_reviewed_at` on plans you review with `update_plan mark_reviewed: true`. For binary files or files larger than 10 MB, call `upload_file` with `content_path` to stage the file and get an `amcs://files/{id}` URI, then pass that URI to `save_file` as `content_uri` to link it to a thought. For small files, use `save_file` or `upload_file` with `content_base64` directly. Browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Stored files can also be read as raw binary via MCP resources at `amcs://files/{id}`. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user. If a tool returns `project_not_found`, call `create_project` with that name and retry — never drop the project scope. Whenever you discover a non-obvious tool behaviour, gotcha, or workflow pattern, record it with `annotate_tool` so future sessions benefit. At session start, identify the project and call `bootstrap_world_model` with a small `context_limit`. Apply mandatory skills and guardrails, but otherwise use summaries and manifests as indexes and load details only when needed. Call `describe_tools` only for discovery or saved notes, preferably with a category filter. Use project scope when the work matches a known project; pass `project` explicitly for stateless clients. Store durable notes with `capture_thought`, curated lessons with `add_learning`, and multi-step work with `create_plan`. Retrieve plans, memories, personas, and files on demand using narrow queries and the smallest useful limits. Never store memory in the wrong scope, silently choose an ambiguous project, or omit an applicable guardrail to save tokens. Record non-obvious tool behavior with `annotate_tool`.
+99
View File
@@ -1,5 +1,104 @@
# AMCS TODO # AMCS TODO
## Memory Consolidation and Retrieval Quality
No prior consolidation implementation notes were found in the project. This roadmap starts clean and follows AMCS's current Go, PostgreSQL/pgvector, thoughts, learnings, projects, and model-specific embeddings architecture.
### Current-shape decisions
- Use bigint thought IDs for foreign keys and queue references, matching the current schema. Keep thought GUIDs as external identifiers rather than introducing UUID foreign keys without a specific interoperability need.
- Embeddings are stored in the separate, model-specific `embeddings` table. Similarity queries and indexes must include the embedding model and project scope where applicable.
- Verify the existing pgvector index strategy before adding another HNSW index. Add or change an index only when the current generated schema and query plan show it is needed.
- All consolidation writes are soft, auditable, idempotent, and reversible.
### Phase 0 - Schema additions
Add to `thoughts`:
- `confidence smallint`
- `source text`
- `valid_until timestamptz`
- `superseded_by bigint` referencing `thoughts.id`
- `salience real`
- `outcome text` constrained to `confirmed`, `refuted`, or null
Add tables:
- `consolidation_queue(id, kind, thought_a, thought_b, status, created_at)`
- `consolidation_audit(id, action, before jsonb, after jsonb, actor, ts)`
The audit record must contain enough information to reverse every consolidation mutation. Define queue uniqueness and status transitions so retries cannot enqueue or process the same pair twice.
Confidence must be available through the existing admin thought resource. Return both the normalized numeric value and enough provenance to explain whether it was user-supplied, heuristic, outcome-derived, or consolidation-derived. Existing thoughts need a documented neutral default or backfill policy rather than silently appearing maximally confident.
### Phase 1 - Algorithmic candidate generation
Run nightly SQL candidate generation without an LLM:
- Near duplicates: self-join model-compatible embeddings and enqueue canonical thought pairs where `1 - (a.embedding <=> b.embedding) > 0.92`.
- Contradiction suspects: combine high cosine similarity with low `pg_trgm` `similarity()` and enqueue as `kind = 'contradiction'`.
- Decay: demote thoughts past `valid_until`; apply recency weighting during retrieval rather than rewriting content.
Candidate queries must de-duplicate ordered pairs, exclude already superseded thoughts, respect project boundaries, and avoid comparing embeddings from different models.
### Phase 2 - Retrieval ranking (ship first)
Change `recall_context` from cosine-only top-k ranking to a bounded composite score based on:
```text
cosine_similarity * exp(-lambda * age_days) * confidence_weight * outcome_weight
```
Exclude superseded and low-salience thoughts from default recall, with explicit options for diagnostic retrieval. Tune defaults conservatively and preserve the current full-text fallback when embeddings are unavailable.
This phase should ship first because it provides the largest recall-quality improvement without requiring consolidation writes or an adjudication model.
### Phase 3 - LLM adjudication worker
Add a resumable Go worker that claims queued pairs and calls a low-cost configured provider only for flagged candidates. Support two structured prompts:
- Merge: produce one normalized schema entry from genuine duplicates.
- Resolve conflict: verify a real contradiction and select or synthesize the supported result.
Writes remain soft: set `superseded_by`, never delete source thoughts, and append every mutation to `consolidation_audit`. Queue claiming, provider calls, and writes must be idempotent and safe across restarts.
### Phase 4 - Salience gate
Compute initial salience during `capture_thought` using explainable heuristics such as length, code presence, decision verbs, and thought type. Store low-salience thoughts but exclude them from default recall. Consider a logistic model only after measured retrieval outcomes show the heuristics have plateaued.
### Phase 5 - Outcome loop
Add a tool or update flag that marks a thought as `confirmed` or `refuted` when its decision plays out. Preserve the change in the consolidation audit and feed the outcome into retrieval ranking as the real-world error signal.
### Phase 6 - Admin confidence and SQL job observability
Extend the Svelte admin UI and its ResolveSpec-compatible backend resources with two read-oriented views.
#### Thought confidence
- Show confidence in thought list and detail views without requiring the operator to open raw metadata.
- Display the numeric value, a human-readable band, provenance, outcome, salience, validity window, and superseded state.
- Allow sorting and filtering by confidence, outcome, validity, and superseded state.
- Visually distinguish unknown/unscored confidence from low confidence; never coerce null into zero or one.
- Explain ranking inputs in the detail view so operators can understand why a thought was included or excluded from default recall.
- Keep confidence mutation behind the normal thought update/audit path rather than allowing unaudited inline table edits.
#### Consolidation SQL jobs
- Show configured `pg_cron` consolidation jobs and their schedule, enabled state, last start/end time, duration, status, and sanitized result/error message.
- Show currently running consolidation jobs separately from recent completed/failed runs using `cron.job` and `cron.job_run_details` through a permission-limited backend projection.
- Include queue counts by status and consolidation kind so a successful cron invocation cannot hide a stalled or growing queue.
- Add filters for job, status, and time range, plus clear empty/unavailable states when `pg_cron` is not installed or its views are inaccessible.
- Do not expose raw SQL command text, connection strings, credentials, or unrestricted `pg_stat_activity` data.
- Keep the first version read-only. Manual run, retry, pause, or cancel controls require separate authorization, audit logging, and explicit confirmation.
- Poll conservatively only while the jobs screen is visible; stop polling when hidden and avoid loading run history during normal admin startup.
Backend tests must cover `pg_cron` unavailable/permission-denied behavior, sanitized errors, running-versus-finished classification, pagination, and project-independent admin authorization. Frontend tests must cover confidence null handling, failed/running job states, queue backlog warnings, and polling cleanup.
### Delivery principle
SQL handles high-volume candidate recall and ranking; the LLM handles low-volume precision decisions for a bounded number of queued pairs. Implement Phase 2 first, then Phase 0/1 foundations needed for consolidation, followed by the worker, salience gate, outcome feedback loop, and admin observability. Confidence fields should appear in the admin UI as soon as Phase 0 lands; SQL job observability should land with the first scheduled Phase 1 jobs.
## Future Plugin: Lifestyle Tools (calendar, meals, household, CRM) ## Future Plugin: Lifestyle Tools (calendar, meals, household, CRM)
The following tool groups have been removed from the core server and are candidates for a separate optional plugin or extension server. The store/tool implementations remain in the codebase but are no longer registered. The following tool groups have been removed from the core server and are candidates for a separate optional plugin or extension server. The store/tool implementations remain in the codebase but are no longer registered.
+15
View File
@@ -0,0 +1,15 @@
# AMCS World Model
AMCS provides a project-scoped world model containing memory, skills, guardrails, personas, and recent context.
At the start of an agent or chat session:
1. Identify the current project from explicit user input or the workspace.
2. Call `list_projects` when the project has not already been confirmed.
3. Call `bootstrap_world_model` with the explicit project name or ID.
4. Apply every returned guardrail and effective skill before continuing.
5. Use the default persona when present. Other linked persona manifests describe personas available on demand.
Never silently choose or create a project. If no project matches, ask the user or create one only with explicit approval. Stateless clients must continue passing the project explicitly on project-scoped calls.
Skills are additive by default. A linked skill with `override: true` replaces a lower-precedence skill with the same stable name. Guardrails are additive and later layers cannot weaken them.
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
-- Many-to-many join table between thoughts and learnings.
-- Allows a learning to reference multiple source thoughts and a thought to
-- expose multiple related learnings, queryable in both directions.
CREATE SEQUENCE IF NOT EXISTS public.identity_thought_learning_links_id
INCREMENT 1
MINVALUE 1
MAXVALUE 9223372036854775807
START 1
CACHE 1;
CREATE TABLE IF NOT EXISTS public.thought_learning_links (
id bigint NOT NULL DEFAULT nextval('public.identity_thought_learning_links_id'),
thought_id bigint NOT NULL REFERENCES public.thoughts(id) ON DELETE CASCADE,
learning_id bigint NOT NULL REFERENCES public.learnings(id) ON DELETE CASCADE,
relation text NOT NULL DEFAULT 'source',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT thought_learning_links_pkey PRIMARY KEY (id),
CONSTRAINT thought_learning_links_unique UNIQUE (thought_id, learning_id)
);
CREATE INDEX IF NOT EXISTS idx_thought_learning_links_thought_id
ON public.thought_learning_links (thought_id);
CREATE INDEX IF NOT EXISTS idx_thought_learning_links_learning_id
ON public.thought_learning_links (learning_id);
+24
View File
@@ -0,0 +1,24 @@
-- Per-user tenancy: authenticate key/client id becomes an opaque tenant boundary.
-- Existing rows remain in the legacy unscoped tenant (NULL) for single-tenant installs.
alter table projects add column if not exists tenant_key text;
alter table thoughts add column if not exists tenant_key text;
alter table stored_files add column if not exists tenant_key text;
alter table learnings add column if not exists tenant_key text;
alter table plans add column if not exists tenant_key text;
alter table chat_histories add column if not exists tenant_key text;
-- Project names are now unique inside a tenant rather than globally.
alter table projects drop constraint if exists ukey_projects_name;
alter table projects drop constraint if exists projects_name_key;
drop index if exists projects_name_key;
create unique index if not exists projects_tenant_key_name_idx
on projects (coalesce(tenant_key, ''), name);
create index if not exists projects_tenant_key_idx on projects (tenant_key);
create index if not exists thoughts_tenant_key_idx on thoughts (tenant_key);
create index if not exists thoughts_tenant_key_project_id_idx on thoughts (tenant_key, project_id);
create index if not exists stored_files_tenant_key_idx on stored_files (tenant_key);
create index if not exists learnings_tenant_key_idx on learnings (tenant_key);
create index if not exists plans_tenant_key_idx on plans (tenant_key);
create index if not exists chat_histories_tenant_key_idx on chat_histories (tenant_key);
+16
View File
@@ -43,6 +43,7 @@ Table agent_persona_skills {
id bigserial [pk] id bigserial [pk]
persona_id bigint [not null, ref: > agent_personas.id] persona_id bigint [not null, ref: > agent_personas.id]
skill_id bigint [not null, ref: > agent_skills.id] skill_id bigint [not null, ref: > agent_skills.id]
override boolean [not null, default: false]
indexes { indexes {
(persona_id, skill_id) [uk] (persona_id, skill_id) [uk]
@@ -50,6 +51,19 @@ Table agent_persona_skills {
} }
} }
Table project_personas {
id bigserial [pk]
project_id bigint [not null, ref: > projects.id]
persona_id bigint [not null, ref: > agent_personas.id]
is_default boolean [not null, default: false]
created_at timestamptz [not null, default: `now()`]
indexes {
(project_id, persona_id) [uk]
project_id
}
}
Table agent_persona_guardrails { Table agent_persona_guardrails {
id bigserial [pk] id bigserial [pk]
persona_id bigint [not null, ref: > agent_personas.id] persona_id bigint [not null, ref: > agent_personas.id]
@@ -124,6 +138,8 @@ Ref: agent_persona_parts.persona_id > agent_personas.id [delete: cascade]
Ref: agent_persona_parts.part_id > agent_parts.id [delete: cascade] Ref: agent_persona_parts.part_id > agent_parts.id [delete: cascade]
Ref: agent_persona_skills.persona_id > agent_personas.id [delete: cascade] Ref: agent_persona_skills.persona_id > agent_personas.id [delete: cascade]
Ref: agent_persona_skills.skill_id > agent_skills.id [delete: cascade] Ref: agent_persona_skills.skill_id > agent_skills.id [delete: cascade]
Ref: project_personas.project_id > projects.id [delete: cascade]
Ref: project_personas.persona_id > agent_personas.id [delete: cascade]
Ref: agent_persona_guardrails.persona_id > agent_personas.id [delete: cascade] Ref: agent_persona_guardrails.persona_id > agent_personas.id [delete: cascade]
Ref: agent_persona_guardrails.guardrail_id > agent_guardrails.id [delete: cascade] Ref: agent_persona_guardrails.guardrail_id > agent_guardrails.id [delete: cascade]
Ref: agent_persona_traits.persona_id > agent_personas.id [delete: cascade] Ref: agent_persona_traits.persona_id > agent_personas.id [delete: cascade]
+27 -1
View File
@@ -6,16 +6,28 @@ Table thoughts {
created_at timestamptz [default: `now()`] created_at timestamptz [default: `now()`]
updated_at timestamptz [default: `now()`] updated_at timestamptz [default: `now()`]
project_id bigint [ref: > projects.id] project_id bigint [ref: > projects.id]
tenant_key text
archived_at timestamptz archived_at timestamptz
indexes {
tenant_key
(tenant_key, project_id)
}
} }
Table projects { Table projects {
id bigserial [pk] id bigserial [pk]
guid uuid [unique, not null, default: `gen_random_uuid()`] guid uuid [unique, not null, default: `gen_random_uuid()`]
name text [unique, not null] name text [not null]
description text description text
tenant_key text
created_at timestamptz [default: `now()`] created_at timestamptz [default: `now()`]
last_active_at timestamptz [default: `now()`] last_active_at timestamptz [default: `now()`]
indexes {
(tenant_key, name) [unique]
tenant_key
}
} }
Table thought_links { Table thought_links {
@@ -32,6 +44,20 @@ Table thought_links {
} }
} }
Table thought_learning_links {
id bigserial [pk]
thought_id bigint [not null, ref: > thoughts.id]
learning_id bigint [not null, ref: > learnings.id]
relation text [not null, default: `'source'`]
created_at timestamptz [not null, default: `now()`]
indexes {
(thought_id, learning_id) [unique]
thought_id
learning_id
}
}
Table embeddings { Table embeddings {
id bigserial [pk] id bigserial [pk]
guid uuid [unique, not null, default: `gen_random_uuid()`] guid uuid [unique, not null, default: `gen_random_uuid()`]
+2
View File
@@ -3,6 +3,7 @@ Table stored_files {
guid uuid [unique, not null, default: `gen_random_uuid()`] guid uuid [unique, not null, default: `gen_random_uuid()`]
thought_id bigint [ref: > thoughts.id] thought_id bigint [ref: > thoughts.id]
project_id bigint [ref: > projects.id] project_id bigint [ref: > projects.id]
tenant_key text
name text [not null] name text [not null]
media_type text [not null] media_type text [not null]
kind text [not null, default: 'file'] kind text [not null, default: 'file']
@@ -16,6 +17,7 @@ Table stored_files {
indexes { indexes {
thought_id thought_id
project_id project_id
tenant_key
sha256 sha256
} }
} }
+4
View File
@@ -6,6 +6,7 @@ Table chat_histories {
channel text channel text
agent_id text agent_id text
project_id bigint [ref: > projects.id] project_id bigint [ref: > projects.id]
tenant_key text
messages jsonb [not null, default: `'[]'`] messages jsonb [not null, default: `'[]'`]
summary text summary text
metadata jsonb [not null, default: `'{}'`] metadata jsonb [not null, default: `'{}'`]
@@ -15,6 +16,7 @@ Table chat_histories {
indexes { indexes {
session_id session_id
project_id project_id
tenant_key
channel channel
agent_id agent_id
created_at created_at
@@ -46,6 +48,7 @@ Table learnings {
source_type text source_type text
source_ref text source_ref text
project_id bigint [ref: > projects.id] project_id bigint [ref: > projects.id]
tenant_key text
related_thought_id bigint [ref: > thoughts.id] related_thought_id bigint [ref: > thoughts.id]
related_skill_id bigint [ref: > agent_skills.id] related_skill_id bigint [ref: > agent_skills.id]
reviewed_by text reviewed_by text
@@ -58,6 +61,7 @@ Table learnings {
indexes { indexes {
project_id project_id
tenant_key
category category
area area
status status
+2
View File
@@ -6,6 +6,7 @@ Table plans {
status text [not null, default: 'draft'] // draft, active, blocked, completed, cancelled, superseded status text [not null, default: 'draft'] // draft, active, blocked, completed, cancelled, superseded
priority text [not null, default: 'medium'] // low, medium, high, critical priority text [not null, default: 'medium'] // low, medium, high, critical
project_id bigint [ref: > projects.id] project_id bigint [ref: > projects.id]
tenant_key text
owner text owner text
due_date timestamptz due_date timestamptz
completed_at timestamptz completed_at timestamptz
@@ -18,6 +19,7 @@ Table plans {
indexes { indexes {
project_id project_id
tenant_key
status status
priority priority
owner owner
+1
View File
@@ -29,6 +29,7 @@ Table project_skills {
id bigserial [pk] id bigserial [pk]
project_id bigint [not null, ref: > projects.id] project_id bigint [not null, ref: > projects.id]
skill_id bigint [not null, ref: > agent_skills.id] skill_id bigint [not null, ref: > agent_skills.id]
override boolean [not null, default: false]
created_at timestamptz [not null, default: `now()`] created_at timestamptz [not null, default: `now()`]
indexes { indexes {
+3 -1
View File
@@ -44,13 +44,15 @@ mkdir -p "${out_dir}"
--from-list "${schema_list}" \ --from-list "${schema_list}" \
--to bun \ --to bun \
--to-path "${out_dir}" \ --to-path "${out_dir}" \
--package generatedmodels --package generatedmodels \
--types resolvespec
"${relspec_bin}" templ \ "${relspec_bin}" templ \
--from dbml \ --from dbml \
--from-list "${schema_list}" \ --from-list "${schema_list}" \
--template "${resolve_spec_template}" \ --template "${resolve_spec_template}" \
--output "${resolve_spec_models_file}" --output "${resolve_spec_models_file}"
# relspec currently emits a few files with unused fmt imports; strip only when fmt is unused. # relspec currently emits a few files with unused fmt imports; strip only when fmt is unused.
for file in "${out_dir}"/*.go; do for file in "${out_dir}"/*.go; do
+2 -1
View File
@@ -143,7 +143,8 @@
unique_tools: raw?.metrics?.unique_tools ?? 0, unique_tools: raw?.metrics?.unique_tools ?? 0,
top_ips: Array.isArray(raw?.metrics?.top_ips) ? raw.metrics.top_ips : [], top_ips: Array.isArray(raw?.metrics?.top_ips) ? raw.metrics.top_ips : [],
top_agents: Array.isArray(raw?.metrics?.top_agents) ? raw.metrics.top_agents : [], top_agents: Array.isArray(raw?.metrics?.top_agents) ? raw.metrics.top_agents : [],
top_tools: Array.isArray(raw?.metrics?.top_tools) ? raw.metrics.top_tools : [] top_tools: Array.isArray(raw?.metrics?.top_tools) ? raw.metrics.top_tools : [],
recent_log: Array.isArray(raw?.metrics?.recent_log) ? raw.metrics.recent_log : []
} }
}; };
} catch (err) { } catch (err) {
+4 -12
View File
@@ -98,12 +98,6 @@
activeCard = id; activeCard = id;
} }
function handleCardKeydown(event: KeyboardEvent, id: string) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setActiveCard(id);
}
}
</script> </script>
<div <div
@@ -205,17 +199,15 @@
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each intelligenceCards as card} {#each intelligenceCards as card}
<article <button
class={`rounded-xl border p-4 transition ${ type="button"
class={`w-full rounded-xl border p-4 text-left transition ${
activeCard === card.id activeCard === card.id
? "border-cyan-300/40 bg-cyan-400/[0.08] shadow-lg shadow-cyan-950/30" ? "border-cyan-300/40 bg-cyan-400/[0.08] shadow-lg shadow-cyan-950/30"
: "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]" : "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]"
}`} }`}
role="button"
tabindex="0"
aria-pressed={activeCard === card.id} aria-pressed={activeCard === card.id}
onclick={() => setActiveCard(card.id)} onclick={() => setActiveCard(card.id)}
onkeydown={(event) => handleCardKeydown(event, card.id)}
> >
<p class={`text-xs uppercase tracking-[0.16em] ${card.accentClass}`}> <p class={`text-xs uppercase tracking-[0.16em] ${card.accentClass}`}>
{card.title} {card.title}
@@ -233,7 +225,7 @@
: ""} : ""}
{/each} {/each}
</p> </p>
</article> </button>
{/each} {/each}
</div> </div>
</div> </div>
+73
View File
@@ -0,0 +1,73 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2025 wdevs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,136 @@
# sqltypes
Nullable SQL types for hand-written or generated Go models. Each type wraps a
value with a `Valid` flag and implements `database/sql.Scanner`,
`driver.Valuer`, `encoding/json`, `gopkg.in/yaml.v3`, and `encoding/xml`
marshalling — so a single struct field can be scanned from a database row,
round-tripped through JSON/YAML/XML, and written back to the database without
any per-format glue code.
This package is what the `bun` and `gorm` writers emit when generating models
with `--types sqltypes` (see [`pkg/writers/bun`](../writers/bun/README.md) and
[`pkg/writers/gorm`](../writers/gorm/README.md)). It can also be imported
directly in hand-written models.
## Import
```go
import sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
```
## Scalar types
All scalar types are instantiations of the generic `SqlNull[T]`:
| Type | Underlying | Typical SQL type |
|---|---|---|
| `SqlInt16` | `int16` | `smallint` |
| `SqlInt32` | `int32` | `integer` |
| `SqlInt64` | `int64` | `bigint` |
| `SqlFloat32` | `float32` | `real`, `float4` |
| `SqlFloat64` | `float64` | `double precision`, `numeric`, `decimal`, `money` |
| `SqlBool` | `bool` | `boolean` |
| `SqlString` | `string` | `text`, `varchar`, `char`, `citext`, `inet`, `cidr`, `macaddr` |
| `SqlByteArray` | `[]byte` | `bytea` (base64-encoded in JSON/YAML/XML) |
| `SqlUUID` | `uuid.UUID` (`github.com/google/uuid`) | `uuid` |
You can also instantiate `SqlNull[T]` directly for any type not covered
above, e.g. `SqlNull[MyEnum]`.
### Date/time types
Plain `time.Time` doesn't distinguish date-only, time-only, and timestamp
semantics, and its zero value marshals to a confusing `0001-01-01T00:00:00Z`.
These wrapper types fix both problems:
| Type | Format | Notes |
|---|---|---|
| `SqlTimeStamp` | `2006-01-02T15:04:05` | Full timestamp |
| `SqlDate` | `2006-01-02` | Date only |
| `SqlTime` | `15:04:05` | Time only |
Zero/pre-epoch values (`time.Time{}` or anything before `0002-01-01`) marshal
to `null` and `Value()` returns `nil`, instead of leaking Go's zero-time
sentinel into the database or API responses.
### JSON types
| Type | Underlying | Notes |
|---|---|---|
| `SqlJSONB` | `[]byte` | Raw JSON bytes; `MarshalYAML` decodes to native YAML mappings/sequences instead of an embedded JSON string |
| `SqlJSON` | `= SqlJSONB` | Alias — PostgreSQL's `json` and `jsonb` share the same Go representation |
`SqlJSONB` has `AsMap()` / `AsSlice()` helpers for pulling out
`map[string]any` / `[]any` without a separate `json.Unmarshal` call.
### Vector type (pgvector)
`SqlVector` wraps `[]float32` for the `vector` column type ([pgvector](https://github.com/pgvector/pgvector)),
scanning/writing the `[1,2,3]` literal format pgvector uses over the wire.
## Array types
PostgreSQL array columns (`text[]`, `integer[]`, …) map to `SqlXxxArray`
types, each wrapping `Val []T` + `Valid bool` and handling PostgreSQL's
`{a,b,c}` array literal format on `Scan`/`Value`:
`SqlStringArray`, `SqlInt16Array`, `SqlInt32Array`, `SqlInt64Array`,
`SqlFloat32Array`, `SqlFloat64Array`, `SqlBoolArray`, `SqlUUIDArray`.
## Constructing values
Every type has a `NewSqlXxx(v)` constructor that sets `Valid: true`:
```go
name := sql_types.NewSqlString("Ada Lovelace")
age := sql_types.NewSqlInt32(36)
tags := sql_types.NewSqlStringArray([]string{"engineer", "mathematician"})
```
The zero value of any type (`sql_types.SqlString{}`) is null/invalid — use it
directly for a `NULL` field instead of a separate constructor.
Generic helpers:
```go
sql_types.Null(v, valid) // SqlNull[T]{Val: v, Valid: valid}
sql_types.NewSql[T](anyValue) // best-effort conversion from any Go value
```
## Reading values back
Each scalar type has typed accessors that return the zero value instead of
panicking when `Valid` is false:
```go
n.Int64() // SqlInt16/32/64, SqlFloat32/64, SqlBool, SqlString → int64
n.Float64() // → float64
n.Bool() // → bool
n.Time() // SqlNull[time.Time]-based types → time.Time
n.UUID() // SqlUUID → uuid.UUID
n.String() // fmt.Stringer — empty string when invalid
```
## Example
```go
type User struct {
ID sql_types.SqlUUID `json:"id"`
Name sql_types.SqlString `json:"name"`
Tags sql_types.SqlStringArray `json:"tags"`
Metadata sql_types.SqlJSONB `json:"metadata"`
CreatedAt sql_types.SqlTimeStamp `json:"created_at"`
}
u := User{
ID: sql_types.NewSqlUUID(uuid.New()),
Name: sql_types.NewSqlString("Ada Lovelace"),
Tags: sql_types.NewSqlStringArray([]string{"engineer"}),
CreatedAt: sql_types.SqlTimeStampNow(),
}
// Metadata left as the zero value → serializes as null, scans as NULL.
```
Every type implements `sql.Scanner` and `driver.Valuer`, so these fields can
be used directly as struct fields with `database/sql`, `bun`, or `gorm`
without additional tags or hooks.
@@ -1,15 +1,85 @@
package spectypes package sqltypes
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"encoding/xml"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"gopkg.in/yaml.v3"
) )
// marshalYAMLSlice returns the value used by yaml.Marshaler implementations
// for the nullable array types below: nil when invalid, the slice otherwise.
func marshalYAMLSlice[T any](valid bool, vals []T) (any, error) {
if !valid {
return nil, nil
}
return vals, nil
}
// unmarshalYAMLSlice returns the value used by yaml.Unmarshaler
// implementations for the nullable array types below.
func unmarshalYAMLSlice[T any](value *yaml.Node) (vals []T, valid bool, err error) {
if value == nil || value.Tag == "!!null" {
return nil, false, nil
}
if err := value.Decode(&vals); err != nil {
return nil, false, err
}
return vals, true, nil
}
// xmlArrayItemName is the element name used for each item when a nullable
// array type is encoded as XML, since XML has no native list type.
var xmlArrayItemName = xml.Name{Local: "item"}
// marshalXMLSlice writes a nullable array type as XML: an empty element when
// invalid, otherwise the start tag followed by one <item> child per element.
func marshalXMLSlice[T any](e *xml.Encoder, start xml.StartElement, valid bool, vals []T) error {
if !valid {
return e.EncodeElement("", start)
}
if err := e.EncodeToken(start); err != nil {
return err
}
for _, v := range vals {
if err := e.EncodeElement(v, xml.StartElement{Name: xmlArrayItemName}); err != nil {
return err
}
}
return e.EncodeToken(start.End())
}
// unmarshalXMLSlice reads back the XML written by marshalXMLSlice.
//
// XML has no native null representation: an element with no <item> children
// unmarshals to a valid, empty slice rather than an invalid one.
func unmarshalXMLSlice[T any](d *xml.Decoder, start xml.StartElement) ([]T, error) {
var vals []T
for {
tok, err := d.Token()
if err != nil {
return nil, err
}
switch t := tok.(type) {
case xml.StartElement:
var v T
if err := d.DecodeElement(&v, &t); err != nil {
return nil, err
}
vals = append(vals, v)
case xml.EndElement:
if t.Name == start.Name {
return vals, nil
}
}
}
}
// parsePostgresArrayElements parses a PostgreSQL array literal (e.g. `{a,"b,c",d}`) // parsePostgresArrayElements parses a PostgreSQL array literal (e.g. `{a,"b,c",d}`)
// into a slice of raw string elements. Each element retains its unquoted/unescaped value. // into a slice of raw string elements. Each element retains its unquoted/unescaped value.
func parsePostgresArrayElements(s string) ([]string, error) { func parsePostgresArrayElements(s string) ([]string, error) {
@@ -140,6 +210,32 @@ func (a *SqlStringArray) UnmarshalJSON(b []byte) error {
return nil return nil
} }
func (a SqlStringArray) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlStringArray) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[string](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlStringArray) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlStringArray) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[string](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlStringArray(v []string) SqlStringArray { func NewSqlStringArray(v []string) SqlStringArray {
return SqlStringArray{Val: v, Valid: true} return SqlStringArray{Val: v, Valid: true}
} }
@@ -215,6 +311,32 @@ func (a *SqlInt16Array) UnmarshalJSON(b []byte) error {
return nil return nil
} }
func (a SqlInt16Array) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlInt16Array) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[int16](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlInt16Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlInt16Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[int16](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlInt16Array(v []int16) SqlInt16Array { func NewSqlInt16Array(v []int16) SqlInt16Array {
return SqlInt16Array{Val: v, Valid: true} return SqlInt16Array{Val: v, Valid: true}
} }
@@ -290,6 +412,32 @@ func (a *SqlInt32Array) UnmarshalJSON(b []byte) error {
return nil return nil
} }
func (a SqlInt32Array) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlInt32Array) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[int32](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlInt32Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlInt32Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[int32](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlInt32Array(v []int32) SqlInt32Array { func NewSqlInt32Array(v []int32) SqlInt32Array {
return SqlInt32Array{Val: v, Valid: true} return SqlInt32Array{Val: v, Valid: true}
} }
@@ -365,6 +513,32 @@ func (a *SqlInt64Array) UnmarshalJSON(b []byte) error {
return nil return nil
} }
func (a SqlInt64Array) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlInt64Array) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[int64](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlInt64Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlInt64Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[int64](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlInt64Array(v []int64) SqlInt64Array { func NewSqlInt64Array(v []int64) SqlInt64Array {
return SqlInt64Array{Val: v, Valid: true} return SqlInt64Array{Val: v, Valid: true}
} }
@@ -440,6 +614,32 @@ func (a *SqlFloat32Array) UnmarshalJSON(b []byte) error {
return nil return nil
} }
func (a SqlFloat32Array) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlFloat32Array) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[float32](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlFloat32Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlFloat32Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[float32](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlFloat32Array(v []float32) SqlFloat32Array { func NewSqlFloat32Array(v []float32) SqlFloat32Array {
return SqlFloat32Array{Val: v, Valid: true} return SqlFloat32Array{Val: v, Valid: true}
} }
@@ -515,6 +715,32 @@ func (a *SqlFloat64Array) UnmarshalJSON(b []byte) error {
return nil return nil
} }
func (a SqlFloat64Array) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlFloat64Array) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[float64](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlFloat64Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlFloat64Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[float64](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlFloat64Array(v []float64) SqlFloat64Array { func NewSqlFloat64Array(v []float64) SqlFloat64Array {
return SqlFloat64Array{Val: v, Valid: true} return SqlFloat64Array{Val: v, Valid: true}
} }
@@ -591,6 +817,32 @@ func (a *SqlBoolArray) UnmarshalJSON(b []byte) error {
return nil return nil
} }
func (a SqlBoolArray) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlBoolArray) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[bool](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlBoolArray) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlBoolArray) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[bool](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlBoolArray(v []bool) SqlBoolArray { func NewSqlBoolArray(v []bool) SqlBoolArray {
return SqlBoolArray{Val: v, Valid: true} return SqlBoolArray{Val: v, Valid: true}
} }
@@ -666,6 +918,32 @@ func (a *SqlUUIDArray) UnmarshalJSON(b []byte) error {
return nil return nil
} }
func (a SqlUUIDArray) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlUUIDArray) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[uuid.UUID](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlUUIDArray) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlUUIDArray) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[uuid.UUID](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlUUIDArray(v []uuid.UUID) SqlUUIDArray { func NewSqlUUIDArray(v []uuid.UUID) SqlUUIDArray {
return SqlUUIDArray{Val: v, Valid: true} return SqlUUIDArray{Val: v, Valid: true}
} }
@@ -750,6 +1028,32 @@ func (v *SqlVector) UnmarshalJSON(b []byte) error {
return nil return nil
} }
func (v SqlVector) MarshalYAML() (any, error) {
return marshalYAMLSlice(v.Valid, v.Val)
}
func (v *SqlVector) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[float32](value)
if err != nil {
return err
}
v.Val, v.Valid = vals, valid
return nil
}
func (v SqlVector) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, v.Valid, v.Val)
}
func (v *SqlVector) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[float32](d, start)
if err != nil {
return err
}
v.Val, v.Valid = vals, true
return nil
}
func NewSqlVector(val []float32) SqlVector { func NewSqlVector(val []float32) SqlVector {
return SqlVector{Val: val, Valid: true} return SqlVector{Val: val, Valid: true}
} }
@@ -1,11 +1,12 @@
// Package spectypes provides nullable SQL types with automatic casting and conversion methods. // Package sqltypes provides nullable SQL types with automatic casting and conversion methods.
package spectypes package sqltypes
import ( import (
"database/sql" "database/sql"
"database/sql/driver" "database/sql/driver"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"encoding/xml"
"fmt" "fmt"
"reflect" "reflect"
"strconv" "strconv"
@@ -13,6 +14,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"gopkg.in/yaml.v3"
) )
// tryParseDT attempts to parse a string into a time.Time using various formats. // tryParseDT attempts to parse a string into a time.Time using various formats.
@@ -120,15 +122,22 @@ func (n *SqlNull[T]) FromString(s string) error {
var zero T var zero T
switch any(zero).(type) { switch any(zero).(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: case int, int8, int16, int32, int64:
if i, err := strconv.ParseInt(s, 10, 64); err == nil { if i, err := strconv.ParseInt(s, 10, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetInt(i) reflect.ValueOf(&n.Val).Elem().SetInt(i)
n.Valid = true n.Valid = true
} } else if f, err := strconv.ParseFloat(s, 64); err == nil {
if f, err := strconv.ParseFloat(s, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetInt(int64(f)) reflect.ValueOf(&n.Val).Elem().SetInt(int64(f))
n.Valid = true n.Valid = true
} }
case uint, uint8, uint16, uint32, uint64:
if u, err := strconv.ParseUint(s, 10, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetUint(u)
n.Valid = true
} else if f, err := strconv.ParseFloat(s, 64); err == nil && f >= 0 {
reflect.ValueOf(&n.Val).Elem().SetUint(uint64(f))
n.Valid = true
}
case float32, float64: case float32, float64:
if f, err := strconv.ParseFloat(s, 64); err == nil { if f, err := strconv.ParseFloat(s, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetFloat(f) reflect.ValueOf(&n.Val).Elem().SetFloat(f)
@@ -232,6 +241,104 @@ func (n *SqlNull[T]) UnmarshalJSON(b []byte) error {
return fmt.Errorf("cannot unmarshal %s into SqlNull[%T]", b, n.Val) return fmt.Errorf("cannot unmarshal %s into SqlNull[%T]", b, n.Val)
} }
// MarshalYAML implements yaml.Marshaler.
func (n SqlNull[T]) MarshalYAML() (any, error) {
if !n.Valid {
return nil, nil
}
// Check if T is []byte, and encode to base64 (mirrors MarshalJSON).
if b, ok := any(n.Val).([]byte); ok {
return base64.StdEncoding.EncodeToString(b), nil
}
return n.Val, nil
}
// UnmarshalYAML implements yaml.Unmarshaler.
func (n *SqlNull[T]) UnmarshalYAML(value *yaml.Node) error {
if value == nil || value.Tag == "!!null" {
n.Valid = false
n.Val = *new(T)
return nil
}
// Check if T is []byte, and decode from base64.
var zero T
if _, ok := any(zero).([]byte); ok {
var s string
if err := value.Decode(&s); err == nil {
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil {
n.Val = any(decoded).(T)
n.Valid = true
return nil
}
n.Val = any([]byte(s)).(T)
n.Valid = true
return nil
}
}
var val T
if err := value.Decode(&val); err == nil {
n.Val = val
n.Valid = true
return nil
}
// Fallback: decode as string and parse.
var s string
if err := value.Decode(&s); err == nil {
return n.FromString(s)
}
return fmt.Errorf("cannot unmarshal %q into SqlNull[%T]", value.Value, n.Val)
}
// MarshalXML implements xml.Marshaler.
func (n SqlNull[T]) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if !n.Valid {
return e.EncodeElement("", start)
}
// Check if T is []byte, and encode to base64 (mirrors MarshalJSON).
if b, ok := any(n.Val).([]byte); ok {
return e.EncodeElement(base64.StdEncoding.EncodeToString(b), start)
}
return e.EncodeElement(n.Val, start)
}
// UnmarshalXML implements xml.Unmarshaler.
//
// XML has no native null representation, so an empty element unmarshals to
// an invalid (null) value rather than a zero-value-but-valid one.
func (n *SqlNull[T]) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
if s == "" {
n.Valid = false
n.Val = *new(T)
return nil
}
var zero T
if _, ok := any(zero).([]byte); ok {
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil {
n.Val = any(decoded).(T)
n.Valid = true
return nil
}
n.Val = any([]byte(s)).(T)
n.Valid = true
return nil
}
return n.FromString(s)
}
// String implements fmt.Stringer. // String implements fmt.Stringer.
func (n SqlNull[T]) String() string { func (n SqlNull[T]) String() string {
if !n.Valid { if !n.Valid {
@@ -329,6 +436,7 @@ type (
SqlInt16 = SqlNull[int16] SqlInt16 = SqlNull[int16]
SqlInt32 = SqlNull[int32] SqlInt32 = SqlNull[int32]
SqlInt64 = SqlNull[int64] SqlInt64 = SqlNull[int64]
SqlFloat32 = SqlNull[float32]
SqlFloat64 = SqlNull[float64] SqlFloat64 = SqlNull[float64]
SqlBool = SqlNull[bool] SqlBool = SqlNull[bool]
SqlString = SqlNull[string] SqlString = SqlNull[string]
@@ -343,7 +451,7 @@ func (t SqlTimeStamp) MarshalJSON() ([]byte, error) {
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) { if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
return []byte("null"), nil return []byte("null"), nil
} }
return []byte(fmt.Sprintf(`"%s"`, t.Val.Format("2006-01-02T15:04:05"))), nil return fmt.Appendf(nil, `"%s"`, t.Val.Format("2006-01-02T15:04:05")), nil
} }
func (t *SqlTimeStamp) UnmarshalJSON(b []byte) error { func (t *SqlTimeStamp) UnmarshalJSON(b []byte) error {
@@ -363,6 +471,49 @@ func (t SqlTimeStamp) Value() (driver.Value, error) {
return t.Val.Format("2006-01-02T15:04:05"), nil return t.Val.Format("2006-01-02T15:04:05"), nil
} }
func (t SqlTimeStamp) MarshalYAML() (any, error) {
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
return nil, nil
}
return t.Val.Format("2006-01-02T15:04:05"), nil
}
func (t *SqlTimeStamp) UnmarshalYAML(value *yaml.Node) error {
if err := t.SqlNull.UnmarshalYAML(value); err != nil {
return err
}
if t.Valid && (t.Val.IsZero() || t.Val.Format("2006-01-02T15:04:05") == "0001-01-01T00:00:00") {
t.Valid = false
}
return nil
}
func (t SqlTimeStamp) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
return e.EncodeElement("", start)
}
return e.EncodeElement(t.Val.Format("2006-01-02T15:04:05"), start)
}
func (t *SqlTimeStamp) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
if s == "" {
t.Valid = false
t.Val = time.Time{}
return nil
}
tm, err := tryParseDT(s)
if err != nil {
return err
}
t.Val = tm
t.Valid = !tm.IsZero() && tm.Format("2006-01-02T15:04:05") != "0001-01-01T00:00:00"
return nil
}
func SqlTimeStampNow() SqlTimeStamp { func SqlTimeStampNow() SqlTimeStamp {
return SqlTimeStamp{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}} return SqlTimeStamp{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
} }
@@ -378,7 +529,7 @@ func (d SqlDate) MarshalJSON() ([]byte, error) {
if strings.HasPrefix(s, "0001-01-01") { if strings.HasPrefix(s, "0001-01-01") {
return []byte("null"), nil return []byte("null"), nil
} }
return []byte(fmt.Sprintf(`"%s"`, s)), nil return fmt.Appendf(nil, `"%s"`, s), nil
} }
func (d *SqlDate) UnmarshalJSON(b []byte) error { func (d *SqlDate) UnmarshalJSON(b []byte) error {
@@ -413,6 +564,57 @@ func (d SqlDate) String() string {
return s return s
} }
func (d SqlDate) MarshalYAML() (any, error) {
if !d.Valid || d.Val.IsZero() {
return nil, nil
}
s := d.Val.Format("2006-01-02")
if strings.HasPrefix(s, "0001-01-01") {
return nil, nil
}
return s, nil
}
func (d *SqlDate) UnmarshalYAML(value *yaml.Node) error {
if err := d.SqlNull.UnmarshalYAML(value); err != nil {
return err
}
if d.Valid && d.Val.Format("2006-01-02") <= "0001-01-01" {
d.Valid = false
}
return nil
}
func (d SqlDate) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if !d.Valid || d.Val.IsZero() {
return e.EncodeElement("", start)
}
s := d.Val.Format("2006-01-02")
if strings.HasPrefix(s, "0001-01-01") {
return e.EncodeElement("", start)
}
return e.EncodeElement(s, start)
}
func (d *SqlDate) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error {
var s string
if err := dec.DecodeElement(&s, &start); err != nil {
return err
}
if s == "" {
d.Valid = false
d.Val = time.Time{}
return nil
}
tm, err := tryParseDT(s)
if err != nil {
return err
}
d.Val = tm
d.Valid = !tm.IsZero() && tm.Format("2006-01-02") > "0001-01-01"
return nil
}
func SqlDateNow() SqlDate { func SqlDateNow() SqlDate {
return SqlDate{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}} return SqlDate{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
} }
@@ -428,7 +630,7 @@ func (t SqlTime) MarshalJSON() ([]byte, error) {
if s == "00:00:00" { if s == "00:00:00" {
return []byte("null"), nil return []byte("null"), nil
} }
return []byte(fmt.Sprintf(`"%s"`, s)), nil return fmt.Appendf(nil, `"%s"`, s), nil
} }
func (t *SqlTime) UnmarshalJSON(b []byte) error { func (t *SqlTime) UnmarshalJSON(b []byte) error {
@@ -455,6 +657,57 @@ func (t SqlTime) String() string {
return t.Val.Format("15:04:05") return t.Val.Format("15:04:05")
} }
func (t SqlTime) MarshalYAML() (any, error) {
if !t.Valid || t.Val.IsZero() {
return nil, nil
}
s := t.Val.Format("15:04:05")
if s == "00:00:00" {
return nil, nil
}
return s, nil
}
func (t *SqlTime) UnmarshalYAML(value *yaml.Node) error {
if err := t.SqlNull.UnmarshalYAML(value); err != nil {
return err
}
if t.Valid && t.Val.Format("15:04:05") == "00:00:00" {
t.Valid = false
}
return nil
}
func (t SqlTime) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if !t.Valid || t.Val.IsZero() {
return e.EncodeElement("", start)
}
s := t.Val.Format("15:04:05")
if s == "00:00:00" {
return e.EncodeElement("", start)
}
return e.EncodeElement(s, start)
}
func (t *SqlTime) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
if s == "" {
t.Valid = false
t.Val = time.Time{}
return nil
}
tm, err := tryParseDT(s)
if err != nil {
return err
}
t.Val = tm
t.Valid = !tm.IsZero() && tm.Format("15:04:05") != "00:00:00"
return nil
}
func SqlTimeNow() SqlTime { func SqlTimeNow() SqlTime {
return SqlTime{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}} return SqlTime{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
} }
@@ -462,6 +715,11 @@ func SqlTimeNow() SqlTime {
// SqlJSONB - Nullable JSONB as []byte. // SqlJSONB - Nullable JSONB as []byte.
type SqlJSONB []byte type SqlJSONB []byte
// SqlJSON - Nullable JSON as []byte. PostgreSQL's json and jsonb types share
// the same textual representation and Go marshalling behavior, differing only
// in server-side storage, so SqlJSON is an alias of SqlJSONB.
type SqlJSON = SqlJSONB
// Scan implements sql.Scanner. // Scan implements sql.Scanner.
func (n *SqlJSONB) Scan(value any) error { func (n *SqlJSONB) Scan(value any) error {
if value == nil { if value == nil {
@@ -518,6 +776,67 @@ func (n *SqlJSONB) UnmarshalJSON(b []byte) error {
return nil return nil
} }
// MarshalYAML implements yaml.Marshaler. The underlying JSON is decoded into
// a generic value first so it renders as native YAML mappings/sequences
// rather than an embedded JSON string.
func (n SqlJSONB) MarshalYAML() (any, error) {
if len(n) == 0 {
return nil, nil
}
var v any
if err := json.Unmarshal(n, &v); err != nil {
return nil, nil
}
return v, nil
}
// UnmarshalYAML implements yaml.Unmarshaler.
func (n *SqlJSONB) UnmarshalYAML(value *yaml.Node) error {
if value == nil || value.Tag == "!!null" {
*n = nil
return nil
}
var v any
if err := value.Decode(&v); err != nil {
return err
}
b, err := json.Marshal(v)
if err != nil {
return err
}
*n = b
return nil
}
// MarshalXML implements xml.Marshaler. JSON has no clean structural mapping
// to XML, so the raw JSON text is emitted as the element's text content.
func (n SqlJSONB) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if len(n) == 0 {
return e.EncodeElement("", start)
}
var obj any
if err := json.Unmarshal(n, &obj); err != nil {
return e.EncodeElement("", start)
}
return e.EncodeElement(string(n), start)
}
// UnmarshalXML implements xml.Unmarshaler, reading back the raw JSON text
// written by MarshalXML.
func (n *SqlJSONB) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
s = strings.TrimSpace(s)
if s == "" {
*n = nil
return nil
}
*n = []byte(s)
return nil
}
func (n SqlJSONB) AsMap() (map[string]any, error) { func (n SqlJSONB) AsMap() (map[string]any, error) {
if len(n) == 0 { if len(n) == 0 {
return nil, nil return nil, nil
@@ -625,6 +944,10 @@ func NewSqlInt64(v int64) SqlInt64 {
return SqlInt64{Val: v, Valid: true} return SqlInt64{Val: v, Valid: true}
} }
func NewSqlFloat32(v float32) SqlFloat32 {
return SqlFloat32{Val: v, Valid: true}
}
func NewSqlFloat64(v float64) SqlFloat64 { func NewSqlFloat64(v float64) SqlFloat64 {
return SqlFloat64{Val: v, Valid: true} return SqlFloat64{Val: v, Valid: true}
} }
+21 -14
View File
@@ -115,32 +115,39 @@ func GetHeadSpecHeaders() []string {
// SetCORSHeaders sets CORS headers on a response writer // SetCORSHeaders sets CORS headers on a response writer
func SetCORSHeaders(w ResponseWriter, r Request, config CORSConfig) { func SetCORSHeaders(w ResponseWriter, r Request, config CORSConfig) {
// Set allowed origins // Reflect the request origin; fall back to wildcard only when no origin is present
// if len(config.AllowedOrigins) > 0 { origin := r.Header("Origin")
// w.SetHeader("Access-Control-Allow-Origin", strings.Join(config.AllowedOrigins, ", ")) if origin == "" {
// } origin = "*"
} else {
// Todo origin list parsing // Vary must be set so caches don't serve one origin's response to another
w.SetHeader("Access-Control-Allow-Origin", "*") httpW := w.UnderlyingResponseWriter()
httpW.Header().Set("Vary", "Origin")
}
w.SetHeader("Access-Control-Allow-Origin", origin)
// Set allowed methods // Set allowed methods
if len(config.AllowedMethods) > 0 { if len(config.AllowedMethods) > 0 {
w.SetHeader("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", ")) w.SetHeader("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
} }
// Set allowed headers // Reflect the preflight request headers when present; otherwise use the explicit config list
// if len(config.AllowedHeaders) > 0 { requestedHeaders := r.Header("Access-Control-Request-Headers")
// w.SetHeader("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", ")) if requestedHeaders != "" {
// } w.SetHeader("Access-Control-Allow-Headers", requestedHeaders)
w.SetHeader("Access-Control-Allow-Headers", "*") } else if len(config.AllowedHeaders) > 0 {
w.SetHeader("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
}
// Set max age // Set max age
if config.MaxAge > 0 { if config.MaxAge > 0 {
w.SetHeader("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge)) w.SetHeader("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge))
} }
// Allow credentials // Allow credentials only when a specific origin is reflected (not wildcard)
w.SetHeader("Access-Control-Allow-Credentials", "true") if origin != "*" {
w.SetHeader("Access-Control-Allow-Credentials", "true")
}
// Expose headers that clients can read // Expose headers that clients can read
exposeHeaders := config.AllowedHeaders exposeHeaders := config.AllowedHeaders
+4
View File
@@ -50,6 +50,10 @@ type ServerInstanceConfig struct {
// GZIP enables GZIP compression middleware // GZIP enables GZIP compression middleware
GZIP bool `mapstructure:"gzip"` GZIP bool `mapstructure:"gzip"`
// HTTP2 enables HTTP/2 with the Extended CONNECT protocol (RFC 8441) for WebSocket support.
// Requires TLS; pair with SSLCert/SSLKey, SelfSignedSSL, or AutoTLS.
HTTP2 bool `mapstructure:"http2"`
// TLS/HTTPS configuration options (mutually exclusive) // TLS/HTTPS configuration options (mutually exclusive)
// Option 1: Provide certificate and key files directly // Option 1: Provide certificate and key files directly
SSLCert string `mapstructure:"ssl_cert"` SSLCert string `mapstructure:"ssl_cert"`
@@ -78,6 +78,17 @@ func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context) return security.GetUserID(s.ctx.Context)
} }
// GetUserRef returns an opaque user identifier for row security lookups.
// It prefers the full *security.UserContext (so providers can read JWT claims,
// e.g. a UUID subject) and falls back to the int user ID.
func (s *securityContext) GetUserRef() (any, bool) {
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
return userCtx, true
}
userID, ok := security.GetUserID(s.ctx.Context)
return userID, ok
}
func (s *securityContext) GetSchema() string { func (s *securityContext) GetSchema() string {
return s.ctx.Schema return s.ctx.Schema
} }
+90 -1
View File
@@ -11,7 +11,8 @@ Type-safe, composable security system for ResolveSpec with support for authentic
-**No Global State** - Each handler has its own security configuration -**No Global State** - Each handler has its own security configuration
-**Testable** - Easy to mock and test -**Testable** - Easy to mock and test
-**Extensible** - Implement custom providers for your needs -**Extensible** - Implement custom providers for your needs
-**Stored Procedures** - All database operations use PostgreSQL stored procedures for security and maintainability -**Stored Procedures** - Database operations use PostgreSQL stored procedures where available, for security and maintainability
-**Direct Mode** - Portable Go/SQL fallback for SQLite, MySQL, or Postgres without the stored procedures installed — no code changes required
-**OAuth2 Authorization Server** - Built-in OAuth 2.1 + PKCE server (RFC 8414, 7591, 7009, 7662) with login form and external provider federation -**OAuth2 Authorization Server** - Built-in OAuth 2.1 + PKCE server (RFC 8414, 7591, 7009, 7662) with login form and external provider federation
-**Password Reset** - Self-service password reset with secure token generation and session invalidation -**Password Reset** - Self-service password reset with secure token generation and session invalidation
@@ -51,6 +52,94 @@ Type-safe, composable security system for ResolveSpec with support for authentic
See `database_schema.sql` for complete stored procedure definitions and examples. See `database_schema.sql` for complete stored procedure definitions and examples.
**Not on Postgres, or don't have the procedures installed?** See [Direct Mode](#direct-mode-portable-sql-without-stored-procedures) below — every provider that calls a `resolvespec_*` procedure also has a portable Go/SQL implementation that works on SQLite, MySQL, or plain Postgres.
## Direct Mode (portable SQL without stored procedures)
Every database-backed provider (`DatabaseAuthenticator`, `JWTAuthenticator`, `DatabaseTwoFactorProvider`, `DatabasePasskeyProvider`, the OAuth2 methods/server, `DatabaseKeyStore`) has two code paths:
- **Procedure mode** — calls the configured `resolvespec_*` stored procedure (original behavior, Postgres-only).
- **Direct mode** — reimplements the same logic in Go using plain parameterized SQL against configurable table names. Works on SQLite, MySQL, or a Postgres database where the procedures were never deployed.
### QueryMode
Selection is controlled per-provider by a `QueryMode`:
```go
type QueryMode int
const (
ModeAuto QueryMode = iota // default
ModeProcedure
ModeDirect
)
```
- **`ModeAuto`** (default, zero value) — auto-detects per connection:
- SQLite/MySQL drivers → Direct mode, no probing.
- Postgres drivers (`lib/pq`, `pgx`) → probes `pg_proc` for the configured procedure name and uses it **only if it actually exists**; otherwise falls back to Direct mode. The result is cached per procedure name and reset on reconnect.
- Any other/unrecognized driver (including `sqlmock` test doubles) → defaults to Procedure mode, preserving existing behavior for callers that don't expose an identifiable driver type.
- **`ModeProcedure`** — always calls the stored procedure, regardless of dialect.
- **`ModeDirect`** — always uses the portable Go/SQL path, never the stored procedure.
Set it via the provider's `Options` struct or `With...` chain method:
```go
auth := security.NewDatabaseAuthenticatorWithOptions(db, security.DatabaseAuthenticatorOptions{
QueryMode: security.ModeDirect, // force Direct mode, e.g. for SQLite
})
tfaProvider := security.NewDatabaseTwoFactorProvider(sqliteDB, nil).
WithQueryMode(security.ModeDirect)
```
On a real SQLite/MySQL connection you can usually leave `QueryMode` unset — `ModeAuto` detects the dialect and uses Direct mode automatically.
### TableNames / KeyStoreTableNames
Direct mode reads/writes plain tables instead of calling procedures, so table names are configurable the same way procedure names are (`SQLNames`):
```go
type TableNames struct {
Users string // default: "users"
UserSessions string // default: "user_sessions"
TokenBlacklist string // default: "token_blacklist"
UserTOTPBackupCodes string // default: "user_totp_backup_codes"
UserPasskeyCredentials string // default: "user_passkey_credentials"
UserPasswordResets string // default: "user_password_resets"
OAuthClients string // default: "oauth_clients"
OAuthCodes string // default: "oauth_codes"
}
type KeyStoreTableNames struct {
UserKeys string // default: "user_keys" — used by DatabaseKeyStore
}
```
`DefaultTableNames()` / `MergeTableNames()` / `ValidateTableNames()` mirror `DefaultSQLNames()` / `MergeSQLNames()` / `ValidateSQLNames()`. Set custom names via the same `Options`/`With...` surface as `QueryMode`:
```go
auth := security.NewDatabaseAuthenticatorWithOptions(db, security.DatabaseAuthenticatorOptions{
TableNames: &security.TableNames{Users: "app_users"}, // only override what differs
})
```
`oauth2_methods.go` and `oauth_server_db.go` are methods on `*DatabaseAuthenticator` and reuse its `TableNames`/`QueryMode`; there's no separate config for them.
### Schema
`database_schema_sqlite.sql` is the portable companion to `database_schema.sql` — plain `CREATE TABLE` statements only (no functions, no triggers, no `jsonb`/`bytea`/array types), covering every table Direct mode reads or writes. Use it to stand up a SQLite (or adapt for MySQL) database for Direct mode.
### What's NOT covered
`ColumnSecurityProvider`/`RowSecurityProvider` (`resolvespec_column_security` / `resolvespec_row_security`) query an external `core.secaccess`/`core.hub_link` schema this package doesn't own. Direct mode has no portable equivalent to fabricate for these and returns `security.ErrDirectModeUnsupported` — use `ConfigColumnSecurityProvider`/`ConfigRowSecurityProvider` instead when not running against Postgres with those procedures installed.
### Behavioral notes
- Direct mode matches Procedure mode's current behavior exactly, including its TODOs — e.g. passwords are compared as-is (the stored procedures don't verify bcrypt hashes yet either; see the TODO in `resolvespec_login`/`resolvespec_password_reset`).
- Session tokens generated by Direct mode use the same `sess_<hex>_<unix-timestamp>` shape as the plpgsql procedures.
- `bytea`/array/`jsonb` Postgres-only columns (passkey credentials, OAuth2 client scopes, keystore `meta`) are stored as base64/JSON-encoded `TEXT` in Direct mode — transparent to callers, since the Go-level API already deals in those same encodings.
## Quick Start ## Quick Start
```go ```go

Some files were not shown because too many files have changed in this diff Show More