Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

60
.dockerignore Normal file
View File

@@ -0,0 +1,60 @@
.git/
.gitea/
# --- Secrets: never let these reach the Docker daemon ---
.env
.env.*
!.env.example
# --- Other services' sources (each build only needs its own) ---
# Deliberately NOT excluded here because the Dockerfiles already COPY
# only the one services/<name>/ directory they need — Docker's build
# context still includes the rest unless listed below. Uncomment the
# ones you don't need for a given service's build, or leave as-is for
# simplicity (COPY-level filtering already prevents cross-contamination).
# services/agent/
# services/billing/
# services/conversation/
# services/gateway/
# services/iam/
# services/knowledge/
# services/notification/
# services/workspace/
# --- Docs (not needed inside any image) ---
*.md
ARCHITECTURE.md
CHANGES.md
README.md
UI-REDESIGN.md
# --- Kubernetes / CI / infra manifests (not part of any Go build) ---
k8s/
.gitea/workflows/
docker-compose.yml
docker-compose.*.yml
# --- Go build artifacts / local tooling ---
/bin/
/dist/
*.test
*.out
coverage.out
coverage.html
/tools/bin/
go.work
go.work.sum
/vendor/
# --- Editor / OS cruft ---
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# --- Misc ---
*.log
tmp/

211
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,211 @@
name: Go CI Pipeline
on: [push, pull_request]
jobs:
build_and_test:
runs-on: ubuntu-latest
# Start a Docker-in-Docker service container
services:
docker:
image: docker:dind
# --dns pins the dind container to reliable public resolvers instead
# of whatever it inherits from the runner host. Without this, the
# embedded 127.0.0.11 resolver inside dind intermittently fails to
# forward lookups for the registry host ("server misbehaving"),
# which otherwise kills `docker push` nondeterministically.
options: --privileged --dns 1.1.1.1 --dns 8.8.8.8
env:
DOCKER_TLS_CERTDIR: ""
# Point docker CLI to the dind container
env:
DOCKER_HOST: tcp://docker:2375
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install dependencies
run: go mod tidy && go mod download
- name: Vet and test
run: |
go vet ./...
go test ./...
- name: Login to private registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push container images
run: |
set -e
REG="${{ secrets.REGISTRY_URL }}/enkly"
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
# Retry helper: docker push against the dind service occasionally
# hits a transient "server misbehaving" DNS failure (127.0.0.11
# couldn't resolve the registry host) rather than a real error.
# Retry a few times with backoff before giving up for real.
push_with_retry() {
local ref="$1"
local attempt=1
local max_attempts=5
until docker push "$ref"; do
if [ "$attempt" -ge "$max_attempts" ]; then
echo "::error::docker push $ref failed after $max_attempts attempts"
return 1
fi
local wait=$((attempt * 5))
echo "push of $ref failed (attempt $attempt/$max_attempts); retrying in ${wait}s..."
sleep "$wait"
attempt=$((attempt + 1))
done
}
for svc in iam billing conversation workspace knowledge agent notification gateway; do
docker build -f services/$svc/Dockerfile -t "$REG/$svc:$SHA" -t "$REG/$svc:latest" .
push_with_retry "$REG/$svc:$SHA"
push_with_retry "$REG/$svc:latest"
done
- name: Build and push vLLM CPU (no AVX512) image
# This is NOT one of the 8 enkly service images above — it's the
# custom vllm/vllm-openai build that k8s/11-vllm.yaml's vllm-chat
# and vllm-embed Deployments require instead of the official
# public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo image, because
# that pre-built image hard-requires avx512f (missing on the
# target cluster's nodes) and crashes with "Illegal instruction"
# without it. Source and build args match k8s/11-vllm.yaml's and
# k8s/README.md's documented command, plus an explicit max_jobs
# cap explained below.
#
# Root cause of the previous failure ("Cannot connect to the
# Docker daemon at tcp://localhost:2375" mid-build): vLLM's
# docker/Dockerfile.cpu defaults to `ARG max_jobs=32`, i.e. 32
# parallel C++ translation units (oneDNN/cutlass kernels etc.)
# compiling at once, each easily using >1GB RES. The dind
# service here — like the target cluster's own nodes (see
# k8s/README.md: ~7949Mi/~7.76Gi per node) — only has a few GB
# of RAM and 2 vCPUs, nowhere near enough for 32-way parallel
# compilation. The build log showed exactly this: individual
# objects going from ~1-10s to 317s (heavy swapping) right
# before the dind container's dockerd was OOM-killed, which
# drops the daemon out from under the still-running `docker
# build` and surfaces as a generic "cannot connect" error rather
# than an explicit OOM message. A plain retry doesn't fix this —
# dockerd itself died, so the service container is gone — only
# bounding parallelism does. We size MAX_JOBS off the runner's
# actual CPU count and memory (~2GB headroom per job) instead of
# trusting the Dockerfile's hardcoded default, floor 1.
#
# Caveat: this clones and compiles vLLM from source on every run
# of this job, on a small runner — expect this step alone to
# take a long time and to be the slowest part of the pipeline.
# If that becomes a problem, the usual fixes are: only run this
# step on a path filter scoped to a pinned vLLM version/tag
# instead of vLLM's default branch, add Docker layer caching
# (e.g. actions/cache or a registry-backed BuildKit cache), or
# move it to its own workflow that isn't triggered on every
# push/PR. We already pin the clone to a tagged vLLM release
# below so a build is reproducible and isn't broken by upstream
# `main` changing under us.
run: |
set -e
REG="${{ secrets.REGISTRY_URL }}/enkly"
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
VLLM_REF="v0.8.5"
push_with_retry() {
local ref="$1"
local attempt=1
local max_attempts=5
until docker push "$ref"; do
if [ "$attempt" -ge "$max_attempts" ]; then
echo "::error::docker push $ref failed after $max_attempts attempts"
return 1
fi
local wait=$((attempt * 5))
echo "push of $ref failed (attempt $attempt/$max_attempts); retrying in ${wait}s..."
sleep "$wait"
attempt=$((attempt + 1))
done
}
# Make sure the dind daemon is actually reachable before we
# kick off a build that can run for tens of minutes.
for i in $(seq 1 30); do
docker info >/dev/null 2>&1 && break
echo "waiting for docker daemon ($i/30)..."
sleep 2
done
docker info >/dev/null 2>&1 || { echo "::error::docker daemon at $DOCKER_HOST is not reachable"; exit 1; }
# Cap compile parallelism to what this runner can actually
# hold in memory, instead of the Dockerfile's hardcoded
# max_jobs=32 (see note above for why that OOM-kills dockerd).
# Budget ~2GB per compile job and never exceed the vCPU count.
CPU_COUNT="$(nproc 2>/dev/null || echo 2)"
MEM_KB="$(awk '/MemTotal/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)"
MEM_JOBS=$((MEM_KB / 1024 / 1024 / 2))
MAX_JOBS="$CPU_COUNT"
if [ "$MEM_JOBS" -gt 0 ] && [ "$MEM_JOBS" -lt "$MAX_JOBS" ]; then
MAX_JOBS="$MEM_JOBS"
fi
[ "$MAX_JOBS" -lt 1 ] && MAX_JOBS=1
echo "Building vLLM with MAX_JOBS=$MAX_JOBS (cpu=$CPU_COUNT, mem_kb=$MEM_KB)"
# `git clone` below runs directly on the runner host (unlike
# `docker build`/push, it never touches $DOCKER_HOST), so the
# --dns 1.1.1.1/8.8.8.8 pin on the dind *service* above does
# nothing for it — that only fixes resolution for things the
# dind daemon itself does (image pulls, registry push). A
# host-level resolver hiccup ("Could not resolve host:
# github.com") is the same class of transient DNS flakiness
# called out in the dind `options` comment, just hitting a
# different resolver. Retry with backoff, and nudge the host
# resolver between attempts where we can (best-effort, never
# fatal if the tool/permission isn't there).
clone_with_retry() {
local attempt=1
local max_attempts=5
until git clone --depth 1 --branch "$VLLM_REF" https://github.com/vllm-project/vllm.git /tmp/vllm-build; do
if [ "$attempt" -ge "$max_attempts" ]; then
echo "::error::git clone of vllm-project/vllm failed after $max_attempts attempts"
return 1
fi
local wait=$((attempt * 5))
echo "git clone failed (attempt $attempt/$max_attempts, likely DNS); retrying in ${wait}s..."
sleep "$wait"
# Best-effort: if the host resolver cache is stuck, poke it.
# Ignore failures - these commands may not exist/be permitted.
(command -v resolvectl >/dev/null 2>&1 && sudo resolvectl flush-caches) || true
(command -v systemd-resolve >/dev/null 2>&1 && sudo systemd-resolve --flush-caches) || true
getent hosts github.com >/dev/null 2>&1 || true
rm -rf /tmp/vllm-build
attempt=$((attempt + 1))
done
}
rm -rf /tmp/vllm-build
clone_with_retry
docker build -f /tmp/vllm-build/docker/Dockerfile.cpu \
--build-arg VLLM_CPU_DISABLE_AVX512=true \
--build-arg max_jobs="$MAX_JOBS" \
--tag "$REG/vllm-cpu:$SHA" \
--tag "$REG/vllm-cpu:latest" \
--target vllm-openai \
/tmp/vllm-build
push_with_retry "$REG/vllm-cpu:$SHA"
push_with_retry "$REG/vllm-cpu:latest"

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
.env
.env.*
!.env.example
# --- Go build artifacts ---
/bin/
/dist/
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
coverage.out
coverage.html
# Go workspace files (only relevant if someone adds a multi-module go.work locally)
go.work
go.work.sum
# Dependency vendoring (this repo doesn't vendor; ignore in case it's added ad hoc)
/vendor/
# --- Protobuf tooling ---
# gen/ is intentionally tracked (every Dockerfile COPYs it as-is,
# see services/*/Dockerfile) — do NOT ignore it. Only ignore the
# plugin binaries `make tools` would install locally.
/tools/bin/
*.pb.go.tmp
# --- Runtime data (bind-mount targets if compose volumes are ever
# switched from named volumes to host paths) ---
/data/
/.cache/huggingface/
/pgdata/
# --- Editor / OS cruft ---
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# --- Misc local scratch ---
*.log
.air.toml
tmp/

332
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,332 @@
# Architecture
This document records the bounded-context decomposition of the
**enkly-ai** monolith, why each boundary is drawn where it is, the
communication rules enforced throughout, and the handful of places
where splitting a single process into eight forced a small, deliberate
change in behavior.
## 1. Bounded contexts (the context map)
The monolith's packages were grouped by **technical layer**
(`internal/handlers`, `internal/db`, `internal/llm`, ...); this port
regroups them by **business capability**, each with its own data and
its own deployable:
```
┌─────────────┐
│ gateway │ (HTTP/HTMX edge — no DB)
└──────┬──────┘
┌──────────┬───────┼────────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼ │
┌───────┐ ┌───────┐┌──────┐┌─────────┐┌─────────┐ │
│ iam │ │billing││ conv │ │workspace││knowledge│ │
└───┬───┘ └───┬───┘└──────┘└─────────┘└────┬────┘ │
│ │ │ │
│ events │ events │ ▼
▼ ▼ │ ┌───────┐
┌──────────────────┐ └──▶│ agent │
│ notification │ └───┬───┘
└──────────────────┘ │
(agent also calls billing,
conversation, workspace,
knowledge — see §3)
```
| Context | Aggregate(s) | Replaces (monolith) |
|---|---|---|
| **IAM** | User, Session, VerificationCode, PasswordResetToken | `internal/handlers/auth.go`, `internal/db/users.go`, `internal/auth/*` |
| **Billing** | Subscription, StripeEvent, DailyUsage | `internal/handlers/billing.go`, `internal/billing/*`, `internal/db/subscriptions.go` |
| **Conversation** | Conversation, Message, ToolCall | `internal/db/conversations.go` |
| **Workspace** | (the sandboxed filesystem itself) | `internal/llm/tools.go` + `sandbox.go`, `internal/handlers/files.go` |
| **Knowledge** | CodeChunk (pgvector document) | `internal/rag/*` |
| **Agent** | Stream (an in-flight run) — a saga coordinator, owns no table | `internal/llm/agent.go` + `client.go`, the run-orchestration half of `internal/handlers/chat.go` |
| **Notification** | (stateless consumer) | the email-sending half of `internal/handlers/auth.go`, `internal/mail/*` |
| **Gateway (BFF)** | (no domain state) | `internal/handlers/{pages,ui,deliverables,middleware}.go`, `internal/render/*` |
**Why Agent is its own context, not folded into Workspace or
Conversation:** the agent run loop is a process that *uses* workspace
(tool execution), knowledge (semantic search) and conversation
(persistence) to do its job, but owns none of their data. That's a
textbook application service / saga, not an aggregate of any of the
three — giving it its own deployable keeps "what tools exist and how a
turn is orchestrated" independently scalable and independently
restartable from "where files live" or "what was said."
**Why Workspace is separate from Agent**, even though today only Agent
calls it: the sandboxed filesystem is also directly reachable by a
human through the gateway's upload/download/explorer panel. Coupling
it to Agent would mean every file-browsing click goes through the LLM
orchestration service for no reason, and would block scaling
"concurrent agent runs" independently of "workspace storage."
**Why rendering lives only in Gateway:** `pkg/renderkit` (Markdown,
syntax highlighting, code-snippet extraction) and every `html/template`
file are a presentation concern, not a domain concept — no bounded
context's *business rule* depends on HTML. Agent streams plain text and
structured tool-call data; Gateway is the one place that turns that
into HTML. This is also what makes Agent's gRPC contract
(`api/proto/agent/v1/agent.proto`) reusable by a future non-HTML client
(a CLI, a different front end) without change.
## 2. Database-per-service
Every context that owns data gets its **own Postgres database** (not
just its own schema/tables in a shared instance):
- `iam` → `enkly_iam` (users, sessions, email_verification_codes, password_reset_tokens)
- `billing` → `enkly_billing` (subscriptions, stripe_events, usage_daily)
- `conversation` → `enkly_conversation` (conversations, messages, tool_calls)
- `knowledge` → `enkly_knowledge` (documents, pgvector)
`workspace` has no database — the sandboxed directory tree itself
*is* its data, on its own Docker volume.
**Consequence worth naming explicitly:** the monolith's `schema.sql`
had `user_id BIGINT REFERENCES users(id) ON DELETE CASCADE` on
`conversations`, `subscriptions`, and `usage_daily`. Postgres cannot
enforce a foreign key across two separate databases, so every one of
those is now a bare `user_id BIGINT` with **no cascade delete**. The
monolith never actually exposed an "delete my account" feature, so no
behavior is lost today — but if one is added later, deleting a user
must become a saga (iam deletes the user, then publishes a
`user.deleted` event for billing/conversation to react to and delete
their own rows), not a single SQL statement. This is the standard,
unavoidable cost of database-per-service and is called out here so it
isn't rediscovered the hard way.
## 3. Communication rules
> *"gRPC for synchronous communication, RabbitMQ for asynchronous
> communication"* — applied throughout as: **if the caller needs an
> answer (or needs to know the write succeeded) before it can proceed,
> it's a gRPC call; if the caller doesn't need to wait for the result,
> it's an event.**
### Synchronous (gRPC) — `api/proto/*/v1/*.proto`
Every cross-context call that's part of a request's critical path:
- Gateway → IAM: session validation on (almost) every request, login, signup, ...
- Gateway → every other service: composing a page from their data
- Agent → Billing: check-and-increment the daily quota before a run starts
- Agent → Conversation: persist each message/tool-call as a turn proceeds
- Agent → Workspace: every filesystem/bash tool call
- Agent → Knowledge: the `search_codebase` tool
- `dispatch_subagent` makes **no new outbound call of its own kind**: it runs a second,
nested `agentrun.Agent` in-process, sharing the Agent service's own vLLM-backed client and
making the *same* Workspace/Knowledge gRPC calls an ordinary tool call would, just via a
`toolexec.restrictedExecutor` that only forwards the read-only subset
(`read_file`/`list_directory`/`glob_search`/`grep`/`search_codebase`). No new service, no
new proto — see `services/agent/internal/infrastructure/toolexec/subagent.go`.
- `solve_ml_math` (like `write_todos`) makes **no** outbound call at all: it's pure
computation over `pkg/mathkit`, a dependency-free numerical engine (linear algebra,
calculus/gradients, statistics, optimization, ML primitives) that runs entirely inside
the agent process — see `pkg/mathkit`'s package doc and
`services/agent/internal/infrastructure/toolexec/mlmath.go`.
- Agent `StreamTurn`: a **server-streaming** RPC — the gRPC replacement
for the monolith's Server-Sent Events, carrying the same
token/thinking/tool-call/done event sequence; the gateway re-emits
each message as an actual SSE frame to the browser (see
`services/gateway/internal/handlers/chat.go`).
### Asynchronous (RabbitMQ) — `pkg/eventbus`
One topic exchange (`enkly.events`); routing keys of the form
`<context>.<aggregate>.<event>`. Today's only real consumer is
`notification`, bound to:
- `iam.user.verification_code_issued` → send the signup/resend email
- `iam.user.password_reset_requested` → send the reset-link email
Also published, with no consumer yet (kept so the *pattern* — every
aggregate commit gets an event — is consistent across every context,
and so a future service can bind without a producer-side change):
`iam.user.registered`, `iam.user.email_verified`,
`billing.subscription.changed`, `conversation.message.created`.
**Trust boundary of the event bus:** `VerificationCodeIssued` and
`PasswordResetRequested` carry the raw code/token on the bus, not just
an ID. This is a deliberate trade-off (documented in
`pkg/eventbus/events/iam_events.go`) rather than an oversight: it lets
`notification` send the exact email the monolith sent without needing
read access to `iam`'s token tables (which would violate
database-per-service far more seriously than this does). It's safe
*only* because RabbitMQ is never exposed outside the private service
network — the same trust level the monolith implicitly gave its own
in-process function call. A production hardening pass should put this
exchange behind TLS + per-service credentials (RabbitMQ supports both)
even on a private network, as defense in depth.
## 4. One Go module, many deployables
Every service shares a single `module enkly` (one `go.mod` at the repo
root) rather than one `go.mod` per service. This is a deliberate,
pragmatic choice: true microservice *independence* comes from separate
processes, separate databases, separate containers, and a versioned
API contract between them (all of which this repo has) — not from
separate Go dependency graphs, which mainly adds `replace`-directive
and version-skew overhead in a monorepo without buying any of the
actual deployability benefits. Each service still has its own
`cmd/server/main.go`, its own `Dockerfile` that builds only that one
binary, and starts, scales, and fails independently of the others.
## 5. Layering within a service
```
internal/domain/ entities + repository interfaces (port). No sql, no net/http, no generated gRPC code.
internal/application/ use cases. Depends on domain only. Transport-agnostic — unit-testable without a server.
internal/infrastructure/ adapters: Postgres repos, the Stripe/vLLM HTTP clients, the sandboxed filesystem.
internal/interfaces/grpc/ the ONLY files that import this service's generated `gen/<ctx>/v1` package.
```
Dependency direction is inward: `interfaces` and `infrastructure` both
depend on `domain` (through its interfaces); `domain` depends on
nothing. This is what lets, e.g., `services/iam/internal/application`
be tested with a fake `UserRepository` and no database at all.
## 6. What's verified, what isn't
Every `.proto` file in `api/proto/` was compiled for real with
`protoc` + the actual `protoc-gen-go`/`protoc-gen-go-grpc` plugins —
`gen/` is genuine generated code, not hand-written stand-ins. Every
hand-written `.go` file in this repo passes `gofmt -l` (verified file
by file as it was written).
What this port could **not** do, for lack of outbound network access
to `proxy.golang.org`/`google.golang.org` in the authoring sandbox: run
`go build`/`go mod tidy` end-to-end across the whole dependency graph
(`google.golang.org/grpc`, `github.com/lib/pq`,
`github.com/rabbitmq/amqp091-go`, `github.com/pgvector/pgvector-go`,
`github.com/gomarkdown/markdown`), and so no `go.sum` is committed.
Every service's Dockerfile therefore runs `go mod tidy` at build time
(after copying in `gen/`, `pkg/`, and that service's own directory) to
resolve and lock the graph on first build — this needs ordinary
internet access, same as any other Go project, and is expected to
succeed on any normal developer machine or CI runner. `make build`
(plain `go build`, no Docker) needs a `go.sum` to exist first, so run
`go mod tidy` at the repo root once before using it locally. Once a
`go.sum` is committed, each Dockerfile's `go mod tidy` line should be
swapped back to `go mod download` (noted inline in
`services/iam/Dockerfile`) for faster, better-cached rebuilds.
The same constraint applied to the later Ollama→vLLM migration
(`pkg/vllmkit`, replacing `pkg/ollamakit`): `pkg/vllmkit` itself has no
dependency beyond the Go standard library, so it was verified for real
— `go build`/`go test` both pass in an isolated module — but every
file that imports it still depends on the same ungenerated `go.sum`,
so those files could only be checked with `gofmt -l` (a full syntax
parse; it does not resolve imports or type-check across packages), not
a full `go build ./...`. Run `go build ./...` (or `make build`) as the
first gate on that change in an environment with normal network
access, before trusting it beyond what `gofmt` already confirms. See
`CHANGES.md`'s most recent entry for the full migration writeup.
## 7. Known gaps / simplifications
Called out here rather than silently shipped, each with why it's safe
to defer:
- **Session validation latency.** `gateway`'s `RequireAuth` now makes
one `IAM.ValidateSession` gRPC call per request, where the monolith
did one local Postgres query in the same process. For most
deployments this is low-single-digit-millisecond overhead on an
internal network and is the correct place to start; if it becomes a
bottleneck, the standard fix is a short-TTL cache in the gateway (or
switching sessions to signed, self-contained tokens) — not
re-merging the services.
- **Reattaching to a run after a page reload.** The monolith could show
a still-in-flight reply immediately on reload
(`StreamManager.activeFor`). The gateway doesn't re-wire this today
(it would need a new "is there an active run for this conversation"
Agent RPC with no other caller) — a reload mid-reply shows the
completed answer on next load instead of resuming the live view. The
reply itself is unaffected; this is UI polish, not correctness.
- **Deleting a conversation mid-run.** The monolith proactively
cancelled any in-flight stream for a conversation being deleted.
Agent's API has no "stop by conversation" RPC (only by `stream_id`),
so a straggling run's writes will fail (logged) against the deleted
conversation rather than being pre-empted. Low-impact and easy to
close later by adding that RPC.
- **Verification/reset email confirmation.** Because sending email is
now asynchronous (RabbitMQ), `iam`'s `SignUp`/`ForgotPassword` RPCs
can no longer report back "the email failed to send" synchronously
the way the monolith occasionally could — they report success once
the code/token is persisted and the event is published. This is the
expected, intentional cost of decoupling a side effect onto a
message bus, not a bug.
- **Per-call bash timeout.** `WorkspaceService.RunBashCommand` accepts
a `timeout_seconds` field for forward-compatibility, but (matching
the monolith, which only ever had one process-wide timeout) does not
yet apply it per call.
- **Chat attachments aren't a schema field.** The composer's "Add
files or photos" (`services/gateway/internal/handlers/attachments.go`)
uploads into the workspace under `attachments/` and folds the
resulting paths into the prompt string itself, as a trailing
`[Attached files]` block — `conversation.Message` has no attachments
field of its own, and adding one would be a schema change across the
conversation/gateway boundary. The trade-off: attachments only ever
round-trip as a specific text shape inside `content`, parsed back out
by `renderUserMessage`, instead of being structured data the
conversation service understands natively. Also: removing a staged
chip before sending, or never sending at all, leaves the uploaded
file behind in the workspace — nothing currently reaps attachments
that were uploaded but never attached to a sent message.
- **Zip attachments extract one level deep.** A `.zip` attached to the
chat is unpacked into `attachments/<name>/` (gateway-side, with its
own zip-slip guard on top of workspace's existing path jail, plus
caps on entry count and total uncompressed size) so the agent's
existing `read_file`/`list_directory`/`grep`/`glob_search` tools can
read what's inside it — no new agent tool needed. A `.zip` nested
inside that `.zip` is written out as-is rather than recursively
extracted; `unzip` isn't in `DefaultAllowedCommands`
(`services/workspace/internal/infrastructure/sandboxfs/sandbox.go`),
so the agent can't shell out to unpack it further unless an operator
adds it to `ENKLY_BASH_ALLOWLIST`.
- **Language preference storage.** The language switcher (`pkg/i18n`,
`services/gateway/internal/handlers/i18n.go`) persists a visitor's
chosen UI locale in a year-long `enkly_lang` cookie, not on the IAM
`User` row. That was a deliberate choice, not an oversight: adding a
`preferred_language` field means extending `iam.proto`'s `User`
message and an RPC, which means regenerating every language's
generated client/server code under `gen/` — this repo's `gen/`
directory is checked in and hand-maintained, and doing that
regeneration safely needs `protoc` and the project's `buf`/codegen
toolchain, which isn't always available in every environment this
code is edited in. A cookie needs neither: it survives logins and
browser restarts, just not a switch to a second device. The same
reasoning is why the visitor's locale reaches the agent service as
gRPC metadata (`x-enkly-lang`, set in `chat.go`'s `withLangMetadata`,
read in `services/agent/internal/interfaces/grpc/server.go`'s
`langFromIncomingContext`) rather than as a new `StartTurnRequest`
field. Moving either onto the User row/proto later is a clean,
backward-compatible addition — `localeFrom`/`langFromIncomingContext`
are the only two places that would need a new "check the account
first" branch — just not one to do without `protoc` on hand to
verify the regenerated code.
- **UI translation coverage.** `pkg/i18n` ships English and French
complete; Spanish, German and Portuguese cover the nav/auth/composer
chrome and fall back to English for the rest (quota/error copy, the
subscription and files panels) — see `pkg/i18n/locales/*.json` and
`T`'s fallback behavior in `pkg/i18n/i18n.go`. `subscription.html`
and `files.html` aren't wired to the switcher yet (no `Lang` field on
their page-data structs). None of this affects how the agent itself
talks to a user — see `agentrun.SystemPrompt`'s "## Language" section
and `agentrun.WithPreferredLanguage`, which work from any locale
regardless of how much of the surrounding chrome has a catalog entry
for it.
## 8. Operational hardening before a real rollout
Deliberately out of scope for this restructuring (it's infrastructure
polish orthogonal to the DDD boundary work), but worth a checklist:
- mTLS between services (today: plaintext gRPC on a private Docker
network — fine inside a single trusted VPC, not fine across one)
- RabbitMQ publisher confirms + a dead-letter exchange for poison
messages (today: at-least-once with a bounded retry via `Nack`)
- Replacing the in-memory `authsupport.Limiter`/`domain.Manager`
(agent's stream registry) with something shared across replicas
(Redis) once any service runs more than one instance
— both are currently per-process, same as the monolith's own
in-memory limiter was

1337
CHANGES.md Normal file

File diff suppressed because it is too large Load Diff

74
Makefile Normal file
View File

@@ -0,0 +1,74 @@
.PHONY: proto build up down logs ps tidy gpu-preflight k8s-deploy k8s-bootstrap
SERVICES := iam billing conversation workspace knowledge agent notification gateway
# Regenerate every *.pb.go / *_grpc.pb.go from api/proto/**/*.proto.
# Requires: protoc, protoc-gen-go, protoc-gen-go-grpc on PATH
# (go install google.golang.org/protobuf/cmd/protoc-gen-go@latest &&
# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest).
proto:
rm -rf gen
mkdir -p gen
protoc -I api/proto \
--go_out=gen --go_opt=module=enkly/gen \
--go-grpc_out=gen --go-grpc_opt=module=enkly/gen \
api/proto/common/v1/*.proto \
api/proto/iam/v1/*.proto \
api/proto/billing/v1/*.proto \
api/proto/conversation/v1/*.proto \
api/proto/workspace/v1/*.proto \
api/proto/knowledge/v1/*.proto \
api/proto/agent/v1/*.proto
# Compile every service's binary locally (sanity check before building
# images) — requires normal internet access to resolve go.mod's
# dependencies, which this sandboxed authoring environment did not
# have; see ARCHITECTURE.md's "What's verified, what isn't".
build:
@for s in $(SERVICES); do \
echo "== $$s =="; \
go build -o /dev/null ./services/$$s/cmd/server || exit 1; \
done
go build -o /dev/null ./tools/indexer/cmd/indexer
tidy:
go mod tidy
up:
# No separate model-pull step needed: vllm-chat/vllm-embed download
# their own weights from Hugging Face on first start, and agent/
# knowledge's depends_on: condition: service_healthy (see
# docker-compose.yml) already blocks them from starting until both
# vLLM servers' /health check passes — which, on a cold cache, can
# take a few minutes for the Qwen3.5 chat model. `make logs` to
# watch that progress.
docker compose up --build -d
down:
docker compose down
logs:
docker compose logs -f
ps:
docker compose ps
# Diagnoses why vllm-chat/vllm-embed are Pending on the k8s deployment
# (GPU capacity, node label/taint, PVC binding) — see k8s/README.md
# "GPU prerequisites" before running this against a real cluster.
gpu-preflight:
./k8s/scripts/gpu-preflight.sh
# Apply + wait for rollout only — assumes the GPU node is already
# labeled/tainted and 02-secrets.yaml is already filled in. Normally
# called by bootstrap-cluster.sh (k8s-bootstrap, below); use this
# directly only when you've done the GPU setup yourself.
k8s-deploy:
cd k8s && ./deploy.sh
# ONE command for the whole k8s/ manifest set: auto-detects + labels/
# taints the GPU node(s), applies time-slicing if needed, generates
# ENKLY_VLLM_API_KEY, applies everything (via k8s-deploy above), then
# runs gpu-preflight. See k8s/README.md "Deploy".
k8s-bootstrap:
cd k8s && ./bootstrap-cluster.sh

178
README.md
View File

@@ -1,2 +1,178 @@
# enkly-ai
# enkly — DDD microservices
This is the monolithic **enkly-ai** codebase (a vLLM-backed agentic
coding assistant with an HTMX chat UI, billing, and a RAG index)
restructured into eight independently deployable,
Domain-Driven-Design-oriented microservices. No product feature was
removed; see [ARCHITECTURE.md](./ARCHITECTURE.md) for the bounded-context
map, the handful of behavioral trade-offs the split required, and what
to review before a production rollout.
Inference runs on two GPU-backed [vLLM](https://docs.vllm.ai)
OpenAI-compatible servers — one serving the chat model (Qwen, with
tool calling and extended thinking enabled), one serving the embedding
model — rather than a single local Ollama daemon. See `pkg/vllmkit`
for the client, `docker-compose.yml` / `k8s/11-vllm.yaml` for how the
two servers are deployed, and `CHANGES.md`'s most recent entry for the
full writeup of what the migration off Ollama changed and why.
## Services
| Service | Port | Owns |
|---|---|---|
| `gateway` | 8080 (HTTP) | The HTMX web UI; the only service with a public HTTP surface |
| `iam` | 9001 (gRPC) | Accounts, sessions, email verification, password reset |
| `billing` | 9002 (gRPC) | Plans, Stripe checkout/portal/webhooks, daily usage quota |
| `conversation` | 9003 (gRPC) | Conversations, messages, tool-call audit log |
| `workspace` | 9004 (gRPC) | The agent's sandboxed filesystem/bash tools; file upload/download |
| `knowledge` | 9005 (gRPC) | pgvector-backed RAG index/search |
| `agent` | 9006 (gRPC) | The LLM run loop; orchestrates billing/conversation/workspace/knowledge |
| `notification` | — (RabbitMQ only) | Sends email in reaction to iam's events |
Communication: **gRPC** for every synchronous, request/response call
between services (see `api/proto/`); **RabbitMQ** for asynchronous,
no-reply-needed side effects — today, exactly the email-sending that
`iam` triggers (see `pkg/eventbus`).
## Running it
```sh
cp .env.example .env # fill in STRIPE_* / ENKLY_SMTP_* if you want those features
docker compose up --build
```
`vllm-chat` and `vllm-embed` download their own model weights from
Hugging Face on first start — no separate `pull` step — so the first
`up` can take several minutes before `agent`/`knowledge` actually come
online (they wait on both vLLM servers' `/health` check via
`depends_on`). `docker compose logs -f vllm-chat` to watch progress.
This requires a host with an NVIDIA GPU and the NVIDIA Container
Toolkit installed (`docker info | grep -i nvidia` to check) — vLLM's
production performance depends on real GPU acceleration; see
`docker-compose.yml`'s `vllm-chat`/`vllm-embed` services for the
details (pinned image tag, `--gpu-memory-utilization`, `/dev/shm`
sizing).
Then open <http://localhost:8080>.
To index a codebase for the `search_codebase` tool:
```sh
docker compose run --rm -v "$(pwd)":/src indexer -path /src
```
## Claude-Code-inspired agent features
Two additions to the agent service, beyond the tool-for-tool monolith port, modeled on
Claude Code:
- **Project memory (`ENKLY.md`)** — drop an `ENKLY.md` file in the sandboxed workspace root
(same mechanism as `write_file`/the file explorer) and the agent folds it into its system
prompt on every turn, under a "Project memory" heading — conventions, gotchas, how to run
things in *this* repo. No file means no change in behavior. See
`services/agent/internal/infrastructure/agentrun/agent.go`'s `MemoryLoader`.
- **`dispatch_subagent` (a Task tool)** — the model can hand off a broad, open-ended
investigation ("find every place X happens", "map out how Y flows through this service")
to a nested, read-only sub-agent that explores on its own and reports back in one message.
The sub-agent's own tool calls never enter the main conversation's context — only its
final report does — and it structurally cannot write a file, edit a file, or run a
command (its tool list excludes them, and its executor refuses them even if asked). See
`services/agent/internal/infrastructure/toolexec/subagent.go` and
`toolcatalog.SubagentDefs()`.
- **Adaptive, interleaved thinking** — the agent already streamed Qwen's reasoning trace
back per round (`ChatEvent.Thinking`); two refinements bring that closer to how Claude's
interleaved thinking actually behaves. First, thinking effort now varies by round instead
of being one static setting for the whole run: round 0 (pure planning, no tool results
yet) asks the chat model for `"high"` effort, later rounds defer to whatever default you've
configured, and a template-parse retry asks for `"low"` so recovery stays fast — see
`agentrun.ThinkPolicy` / `DefaultThinkPolicy`. Second, a round's reasoning trace is no
longer resent to the model on every subsequent round for the rest of the turn: once a
newer round's thinking exists, the older one is dropped from the resent history (only the
most recent round's trace is ever "in flight," matching the Claude API's own behavior of
only needing the thinking block immediately preceding the current tool use) — see
`agentrun.compactThinkingHistory`, called alongside the existing `compactToolHistory` on
every round. A new optional `Callbacks.OnThinkingRound(round int, thinking string)` also
fires once per round with just that round's own trace, for a caller that wants to render
reasoning as discrete per-step blocks rather than one running blob. The gateway now does
exactly that: `domain.Stream.AddThinkingRound` records each round, `agent.proto`'s new
`TurnEvent.thinking_round` (a `ThinkingRound{round, thinking}` oneof case) carries it over
gRPC, and `StreamChat` emits it as a `thinking_round` SSE event rendered as its own
collapsible card (`message.html`'s `thinking_round`/`thinking_round_oob`, styled via
`.tool-card.is-thinking-round` in `style.css`) inserted above the reply in execution
order — the same `beforebegin` OOB trick tool-call cards already use. This sits alongside,
not in place of, the existing single auto-collapsing `.thinking-card`: that one shows the
round currently in flight streaming live; the per-round cards are the settled record of
every round once done, positioned right before the tool call each one led to.
## Dynamic workflows, effort control, and long-context recovery
A further round of additions, modeled on Claude's agentic-coding feature set and scoped
to what's honestly deliverable against a vLLM-served Qwen backend — see `CHANGES.md`
§7 for the full writeup (written against the Ollama backend these features originally
shipped on; see `CHANGES.md`'s latest entry for what the later vLLM migration changed).
The short version:
- **`dispatch_workflow`** — the large-scale sibling of `dispatch_subagent`: give it a
batch of steps (one per file/service/call site in a migration) and it runs them as
parallel, read-only sub-agents, honoring any `depends_on` ordering, verifying each
report against its own task, and retrying once on a weak one — automatic verification
and plan adjustment, without a second LLM call per step. Gated by plan
(`pkg/entitlements`) and bounded by `ENKLY_WORKFLOW_MAX_PARALLEL`.
- **Effort control** (`pkg/effort`) — five levels, Low through Max, picked per turn via a
leading `/effort <level>` line in the chat message (see `pkg/turnopts` for why a
directive line rather than a new proto field, for now) or `ENKLY_DEFAULT_EFFORT` for the
deployment default. Each level sizes max rounds, context budget, thinking-effort floor,
and `dispatch_workflow`'s own parallelism cap.
- **Adaptive thinking** — escalates one round's thinking effort above its level's floor on
a concrete difficulty signal (the prior tool call errored, produced an unusually large
result, or the turn is running long), instead of holding every round at one fixed
setting. On by default (`ENKLY_ADAPTIVE_THINKING`).
- **Compaction ledger** — `compactToolHistory` now keeps a running, auto-maintained note
of every tool result it's trimmed away, so a long turn that's compacted its 30th result
still has a stable map of what happened earlier instead of silently losing the thread.
- **Live system entries** — `/system <instruction>` (and `permission:`/`budget:` variants)
fold additional guidance into the system prompt mid-conversation, always appended after
the stable base prompt rather than edited in place.
- **Fast mode** — `/fast` runs the turn on a second, smaller/quantized model served by
its own vLLM deployment (`ENKLY_FAST_CHAT_MODEL` + `ENKLY_FAST_VLLM_CHAT_URL`) when
one's configured; otherwise it's a no-op, so it's always safe to request.
## First build
No `go.sum` is committed. Every Dockerfile runs `go mod tidy` during
the build to resolve and lock the full dependency graph (including
what grpc/protobuf/etc. pull in transitively) — this needs normal
outbound network access the first time, same as any other Go project.
After the first successful `docker compose up --build`, consider
running `go mod tidy` once at the repo root and committing the
resulting `go.sum`, then switching each Dockerfile's `RUN go mod tidy`
back to `RUN go mod download` for faster, better-cached rebuilds (see
the comment in `services/iam/Dockerfile`).
## Regenerating gRPC code
`gen/` is committed so the repo is buildable without protoc installed,
but if you edit anything under `api/proto/`, regenerate it:
```sh
make proto # requires protoc, protoc-gen-go, protoc-gen-go-grpc on PATH
```
## Repository layout
```
api/proto/<context>/v1/*.proto gRPC contracts — the API surface between services
gen/ generated *.pb.go / *_grpc.pb.go (do not hand-edit)
pkg/ shared libraries (eventbus, auth helpers, render, vLLM client, Postgres helper, ...)
services/<name>/
cmd/server/main.go entrypoint: wiring, not logic
internal/domain/ entities, repository interfaces — no framework imports
internal/application/ use cases — transplanted business logic, transport-agnostic
internal/infrastructure/ Postgres/Stripe/vLLM/filesystem adapters implementing domain interfaces
internal/interfaces/grpc/ the only files that import the generated gRPC package
migrations/0001_init.sql this service's own schema (database-per-service)
tools/indexer/ standalone CLI, a knowledge-service gRPC client
docker-compose.yml full local stack
```
See [ARCHITECTURE.md](./ARCHITECTURE.md) for the design rationale.

19
UI-REDESIGN.md Normal file
View File

@@ -0,0 +1,19 @@
# enkly UI redesign (gateway web)
Touched: `web/static/css/style.css` (rewritten), `web/static/js/app.js` (additions at the end),
`web/templates/index.html`, `web/templates/tree.html` (status bar). All other templates and every
Go handler are unchanged; class names used by Go-rendered fragments (tool cards, thinking, deliverables,
attachments, tree, auth, billing) are preserved.
## Design moves (from the reference screenshot)
- Glowing command bar -> top-bar search that opens a command palette (Cmd/Ctrl+K): chats + quick actions.
- Glow-ring composer with "+" and a pill send button ("Ask") -> composer is the single luminous element.
- Recipe cards -> "Start with" starter cards (click fills or sends a prompt).
- "Ingredients you have" -> "What enkly can use" tool chips; Explorer is now a glass panel.
- "More to explore" -> dashed prompt chips that fill the composer.
- Source cards -> agent tool steps with a per-kind coloured icon (read / write / search / run / plan / sub-agent).
- Context usage in the status bar is now a meter (amber at 70%, red at 90%).
## Constraints respected
System fonts only, no inline scripts, no CDN, no new endpoints (CSP: script-src 'self').
Light and dark themes both supported; reduced-motion respected; visible focus rings.

View File

@@ -0,0 +1,159 @@
// Package agent is the Agent Orchestration bounded context: the agentic
// run loop (prompt -> vLLM -> tool calls -> final reply), the chat-run
// quota/concurrency guard, and the in-flight/just-finished run registry
// that used to live in internal/handlers/chat.go's StreamManager.
//
// This is a saga coordinator, not a data owner: it holds no SQL table of
// its own. It calls billing (usage quota), conversation (persisting
// turns) and knowledge (the search_codebase tool) over gRPC, and
// workspace (every filesystem/bash tool) over gRPC, exactly replacing
// internal/llm/agent.go + internal/llm/tools.go's direct, in-process
// calls into internal/db and the local filesystem.
//
// Rendering (Markdown, syntax highlighting, deliverable cards) stays out
// of this service on purpose — see internal/render's doc comment in the
// gateway service — so this API deals in plain text and structured tool
// results, never HTML.
syntax = "proto3";
package enkly.agent.v1;
option go_package = "enkly/gen/agent/v1;agentv1";
service AgentService {
// StartTurn replaces the synchronous prefix of Handlers.Chat: the
// per-user throttle (ChatLimiter), the billing quota check, loading
// the conversation, persisting the user's prompt, auto-titling, and
// registering a new run (StreamManager.create) before handing off to
// a background goroutine. Returns immediately with a stream_id; the
// reply itself arrives via StreamTurn.
rpc StartTurn(StartTurnRequest) returns (StartTurnResponse);
// StreamTurn replaces Handlers.StreamChat: a server-streaming view of
// one run's progress. resume_from_tool_index mirrors the monolith's
// SSE Last-Event-ID handling, so a reconnecting client doesn't see
// tool cards it already has.
rpc StreamTurn(StreamTurnRequest) returns (stream TurnEvent);
// StopTurn replaces Handlers.StopStream.
rpc StopTurn(StopTurnRequest) returns (StopTurnResponse);
}
enum StartTurnOutcome {
START_TURN_OUTCOME_UNSPECIFIED = 0;
START_TURN_OUTCOME_OK = 1;
START_TURN_OUTCOME_CONVERSATION_NOT_FOUND = 2;
START_TURN_OUTCOME_CONVERSATION_BUSY = 3;
START_TURN_OUTCOME_TOO_MANY_STREAMS = 4;
START_TURN_OUTCOME_RATE_LIMITED = 5;
START_TURN_OUTCOME_QUOTA_EXCEEDED = 6;
START_TURN_OUTCOME_INTERNAL_ERROR = 7;
}
// SystemEntry is a mid-conversation instruction/permission/budget
// update — see agentrun.SystemEntry (services/agent/internal/
// infrastructure/agentrun/effort.go) for what each kind means and how
// it's folded into the system prompt.
message SystemEntry {
enum Kind {
KIND_UNSPECIFIED = 0;
KIND_INSTRUCTION = 1;
KIND_PERMISSION = 2;
KIND_BUDGET = 3;
}
Kind kind = 1;
string content = 2;
}
message StartTurnRequest {
int64 user_id = 1;
int64 conversation_id = 2;
string prompt = 3;
// --- Fields below this line are the TARGET structured API surface
// for effort control, fast mode and live system-entry updates — see
// pkg/turnopts' package doc comment for why they aren't wired up yet
// (this environment has no protoc/go toolchain to regenerate
// gen/agent/v1 from this file) and what the working fallback is
// today (leading /effort, /fast, /system directive lines parsed out
// of `prompt` itself, by pkg/turnopts.Parse — see
// application.Service.StartTurn). Once `make proto` is run against
// this file, a follow-up change points StartTurn at these fields
// directly, with turnopts kept on as the fallback path for any
// client that still sends plain-text directives instead. Proto3
// fields are purely additive and backward compatible to add, so
// declaring them now costs nothing even before that regeneration —
// every field below defaults to its zero value for any client built
// against today's compiled stubs.
// effort selects one of pkg/effort's five levels ("low", "medium",
// "high", "extra", "max") for this turn; empty means "use the
// deployment's configured default" (see agentrun.Agent.Effort).
string effort = 4;
// fast_mode requests fast mode for this turn (see
// application.Service.FastAgent) — available on every plan, unlike
// dynamic_workflows below.
bool fast_mode = 5;
// system_entries are folded into the system prompt for this turn
// onward (see agentrun.WithSystemEntries) — appended, never
// replacing anything, so earlier turns' prefix stays stable.
repeated SystemEntry system_entries = 6;
}
message StartTurnResponse {
StartTurnOutcome outcome = 1;
string message = 2; // user-facing text for any non-OK outcome
string stream_id = 3; // set when outcome == OK
}
message StreamTurnRequest {
int64 user_id = 1;
string stream_id = 2;
int32 resume_from_tool_index = 3;
}
message ToolCall {
string name = 1;
string arguments_json = 2;
string result = 3;
bool is_error = 4;
}
// ThinkingRound is one agent round's own reasoning trace, standalone
// rather than folded into the running total thinking_delta carries —
// the same per-step granularity Claude's interleaved thinking exposes
// (one distinct thinking block per tool-use step) rather than one
// cumulative blob for the whole turn. round is 0-indexed and matches
// the round the agent service's internal run loop was on when this
// trace was produced; a client can use it to render reasoning as
// discrete, collapsible steps next to the tool call each one preceded,
// instead of a single growing "thinking" pane.
message ThinkingRound {
int32 round = 1;
string thinking = 2;
}
message TurnEvent {
oneof event {
string thinking_delta = 1; // cumulative reasoning text so far, plain
string token_delta = 2; // cumulative reply text so far, plain
ToolCall tool_call = 3; // one completed tool call
TurnDone done = 4;
ThinkingRound thinking_round = 5; // one round's own reasoning trace, standalone
}
}
message TurnDone {
string final_text = 1; // plain Markdown source, not yet rendered
// every tool call made during the turn, successful or not — the
// gateway uses this to build deliverable (download) cards exactly
// like internal/handlers/deliverables.go.attachDeliverables.
repeated ToolCall tool_calls = 2;
}
message StopTurnRequest {
int64 user_id = 1;
string stream_id = 2;
}
message StopTurnResponse {}

View File

@@ -0,0 +1,153 @@
// Package billing is the Billing & Subscription bounded context: plans,
// Stripe checkout/portal, webhook ingestion, and the per-plan daily
// message quota. System of record for `subscriptions`, `stripe_events`
// and `usage_daily` (see services/billing/migrations/0001_init.sql).
//
// Mirrors internal/handlers/billing.go + internal/billing/*.go +
// internal/db/subscriptions.go.
syntax = "proto3";
package enkly.billing.v1;
option go_package = "enkly/gen/billing/v1;billingv1";
import "google/protobuf/timestamp.proto";
service BillingService {
// GetOrCreateSubscription replaces db.GetOrCreateSubscription: lazily
// inserts the default free-plan row the first time a user's
// subscription is looked up.
rpc GetOrCreateSubscription(GetOrCreateSubscriptionRequest) returns (Subscription);
// GetPlanCatalog replaces billing.Catalog + billing.PlanPriceIDs for
// rendering /subscription — returns every plan and whether its Stripe
// price is currently configured (so an unconfigured plan renders as
// "Unavailable", same as today).
rpc GetPlanCatalog(GetPlanCatalogRequest) returns (GetPlanCatalogResponse);
// CreateCheckoutSession replaces Handlers.CreateCheckout.
rpc CreateCheckoutSession(CreateCheckoutSessionRequest) returns (CreateCheckoutSessionResponse);
// SyncCheckoutSession replaces Handlers.syncCheckoutSession, the
// fallback path SubscriptionSuccess uses when the webhook hasn't
// landed yet.
rpc SyncCheckoutSession(SyncCheckoutSessionRequest) returns (Subscription);
// CreateBillingPortalSession replaces Handlers.BillingPortal.
rpc CreateBillingPortalSession(CreateBillingPortalSessionRequest) returns (CreateBillingPortalSessionResponse);
// IngestStripeWebhook replaces Handlers.StripeWebhook /
// applyStripeEvent. The gateway still terminates the public
// POST /webhooks/stripe route (so the URL Stripe is configured with
// never changes) but forwards the raw body and signature header here
// untouched — signature verification needs STRIPE_WEBHOOK_SECRET,
// which only billing holds.
rpc IngestStripeWebhook(IngestStripeWebhookRequest) returns (IngestStripeWebhookResponse);
// CheckAndIncrementUsage replaces the daily-cap check + db.
// IncrementDailyUsage call in Handlers.Chat. Called by the agent
// service once per chat turn, after the message is accepted and
// before the agent run starts (same ordering as the monolith).
rpc CheckAndIncrementUsage(CheckAndIncrementUsageRequest) returns (CheckAndIncrementUsageResponse);
// GetDailyUsage replaces db.DailyUsage — a read-only lookup (no
// increment) used by the gateway's /subscription page to show
// today's count without miscounting it as a sent message.
rpc GetDailyUsage(GetDailyUsageRequest) returns (GetDailyUsageResponse);
}
enum Plan {
PLAN_UNSPECIFIED = 0;
PLAN_FREE = 1;
PLAN_BASIC = 2;
PLAN_PRO = 3;
PLAN_PREMIUM = 4;
}
message PlanInfo {
Plan plan = 1;
string name = 2;
int32 price_eur = 3;
int32 daily_message_limit = 4; // 0 = unlimited
bool available = 5; // Stripe price configured (always true for free)
}
message Subscription {
int64 user_id = 1;
Plan plan = 2;
string status = 3; // Stripe's subscription status, or "active" for free
google.protobuf.Timestamp current_period_end = 4;
bool cancel_at_period_end = 5;
// has_billing_account reports whether a Stripe customer exists for
// this user (so the gateway knows whether to show "Manage billing"),
// without exposing the actual customer ID. The raw
// stripe_customer_id / stripe_subscription_id / stripe_price_id are
// deliberately NOT exposed here, mirroring models.Subscription's
// json:"-" — they never need to leave billing.
bool has_billing_account = 6;
}
message GetOrCreateSubscriptionRequest {
int64 user_id = 1;
}
message GetPlanCatalogRequest {}
message GetPlanCatalogResponse {
repeated PlanInfo plans = 1;
string publishable_key = 2; // Stripe publishable key, for Checkout.js
}
message CreateCheckoutSessionRequest {
int64 user_id = 1;
string email = 2;
Plan plan = 3;
string success_url = 4;
string cancel_url = 5;
}
message CreateCheckoutSessionResponse {
string checkout_url = 1;
}
message SyncCheckoutSessionRequest {
int64 user_id = 1;
string stripe_session_id = 2;
}
message CreateBillingPortalSessionRequest {
int64 user_id = 1;
string return_url = 2;
}
message CreateBillingPortalSessionResponse {
string portal_url = 1;
}
message IngestStripeWebhookRequest {
bytes payload = 1;
string signature_header = 2;
}
message IngestStripeWebhookResponse {
bool accepted = 1;
}
message CheckAndIncrementUsageRequest {
int64 user_id = 1;
}
message CheckAndIncrementUsageResponse {
bool allowed = 1;
int32 used = 2;
int32 limit = 3; // 0 = unlimited
string plan_name = 4;
}
message GetDailyUsageRequest {
int64 user_id = 1;
}
message GetDailyUsageResponse {
int32 used = 1;
}

View File

@@ -0,0 +1,28 @@
// Package common holds message types shared across every enkly bounded
// context's gRPC API, so each context's own .proto only needs to import
// this one file instead of depending on each other directly.
syntax = "proto3";
package enkly.common.v1;
option go_package = "enkly/gen/common/v1;commonv1";
// Money is a decimal amount in the smallest unit of currency (cents),
// matching how internal/billing.Info prices plans today (EUR integer
// amounts) without pulling in a floating point rounding bug.
message Money {
int64 minor_units = 1; // e.g. 500 = 5.00
string currency_code = 2; // ISO 4217, e.g. "EUR"
}
// PageRequest / PageResponse: not used by today's list endpoints (the
// monolith never paginates conversations or messages), reserved so a
// service can add pagination later without a breaking proto change.
message PageRequest {
int32 page_size = 1;
string page_token = 2;
}
message PageResponse {
string next_page_token = 1;
}

View File

@@ -0,0 +1,136 @@
// Package conversation is the Conversation bounded context: chat threads
// and their messages/tool-call audit log. System of record for
// `conversations`, `messages` and `tool_calls` (see
// services/conversation/migrations/0001_init.sql).
//
// Mirrors internal/db/conversations.go, called both by the gateway
// (listing/renaming/deleting threads, loading history to render) and by
// the agent service (appending messages as a run proceeds).
syntax = "proto3";
package enkly.conversation.v1;
option go_package = "enkly/gen/conversation/v1;conversationv1";
import "google/protobuf/timestamp.proto";
service ConversationService {
rpc CreateConversation(CreateConversationRequest) returns (Conversation);
rpc GetConversation(GetConversationRequest) returns (Conversation);
rpc ListConversations(ListConversationsRequest) returns (ListConversationsResponse);
rpc MostRecentConversation(MostRecentConversationRequest) returns (Conversation);
rpc RenameConversation(RenameConversationRequest) returns (Conversation);
// AutoTitleConversation replaces db.AutoTitleConversation: sets the
// title from the first prompt only if the conversation still has the
// default title (returns retitled=true when it actually changed it).
rpc AutoTitleConversation(AutoTitleConversationRequest) returns (AutoTitleConversationResponse);
rpc DeleteConversation(DeleteConversationRequest) returns (DeleteConversationResponse);
rpc CountMessages(CountMessagesRequest) returns (CountMessagesResponse);
rpc AddMessage(AddMessageRequest) returns (Message);
rpc ListMessages(ListMessagesRequest) returns (ListMessagesResponse);
rpc AddToolCall(AddToolCallRequest) returns (AddToolCallResponse);
}
message Conversation {
int64 id = 1;
int64 user_id = 2;
string title = 3;
google.protobuf.Timestamp created_at = 4;
google.protobuf.Timestamp last_message_at = 5;
}
enum Role {
ROLE_UNSPECIFIED = 0;
ROLE_USER = 1;
ROLE_ASSISTANT = 2;
ROLE_SYSTEM = 3;
ROLE_TOOL = 4;
}
message Message {
int64 id = 1;
int64 conversation_id = 2;
Role role = 3;
string content = 4;
string tool_name = 5;
google.protobuf.Timestamp created_at = 6;
}
message CreateConversationRequest {
int64 user_id = 1;
string title = 2;
}
message GetConversationRequest {
int64 user_id = 1;
int64 conversation_id = 2;
}
message ListConversationsRequest {
int64 user_id = 1;
}
message ListConversationsResponse {
repeated Conversation conversations = 1;
}
message MostRecentConversationRequest {
int64 user_id = 1;
}
message RenameConversationRequest {
int64 user_id = 1;
int64 conversation_id = 2;
string title = 3;
}
message AutoTitleConversationRequest {
int64 user_id = 1;
int64 conversation_id = 2;
string title = 3;
}
message AutoTitleConversationResponse {
bool retitled = 1;
}
message DeleteConversationRequest {
int64 user_id = 1;
int64 conversation_id = 2;
}
message DeleteConversationResponse {}
message CountMessagesRequest {
int64 conversation_id = 1;
}
message CountMessagesResponse {
int32 count = 1;
}
message AddMessageRequest {
int64 conversation_id = 1;
Role role = 2;
string content = 3;
string tool_name = 4;
}
message ListMessagesRequest {
int64 conversation_id = 1;
}
message ListMessagesResponse {
repeated Message messages = 1;
}
message AddToolCallRequest {
int64 message_id = 1;
string tool_name = 2;
string arguments_json = 3;
string result = 4;
bool is_error = 5;
}
message AddToolCallResponse {}

165
api/proto/iam/v1/iam.proto Normal file
View File

@@ -0,0 +1,165 @@
// Package iam is the Identity & Access bounded context: accounts,
// sessions, email verification and password reset. It is the system of
// record for `users`, `sessions`, `email_verification_codes` and
// `password_reset_tokens` (see services/iam/migrations/0001_init.sql) —
// no other service may read or write those tables directly.
//
// Mirrors the monolith's internal/handlers/auth.go + internal/db/users.go
// + internal/auth/*.go one-for-one; see each rpc's comment for the
// handler function it replaces.
syntax = "proto3";
package enkly.iam.v1;
option go_package = "enkly/gen/iam/v1;iamv1";
import "google/protobuf/timestamp.proto";
service IAMService {
// SignUp replaces Handlers.Signup. Validates and normalizes the email,
// hashes the password (PBKDF2, see internal/auth/password.go), creates
// the account (or silently reuses an existing unverified one — same
// anti-enumeration behavior as the monolith), issues a verification
// code and publishes an iam.verification_code.issued event for the
// notification service to deliver it. Never returns whether the email
// already existed.
rpc SignUp(SignUpRequest) returns (SignUpResponse);
// ResendVerification replaces Handlers.ResendVerification.
rpc ResendVerification(ResendVerificationRequest) returns (ResendVerificationResponse);
// VerifyEmail replaces Handlers.VerifyEmail.
rpc VerifyEmail(VerifyEmailRequest) returns (VerifyEmailResponse);
// Login replaces Handlers.Login: verifies credentials (constant-time
// against a dummy hash for unknown emails) and, on success, mints a
// session and returns its raw token for the gateway to set as a
// cookie. Never persists the raw token — only its SHA-256 hash.
rpc Login(LoginRequest) returns (LoginResponse);
// Logout replaces Handlers.Logout: deletes the session row for the
// given raw session token.
rpc Logout(LogoutRequest) returns (LogoutResponse);
// ValidateSession replaces the session half of Handlers.RequireAuth /
// db.GetSessionUser. Called by the gateway on every authenticated
// request.
rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse);
// ForgotPassword replaces Handlers.ForgotPassword.
rpc ForgotPassword(ForgotPasswordRequest) returns (ForgotPasswordResponse);
// CheckPasswordResetToken replaces the GET-time validity check in
// Handlers.ResetPasswordPage (db.PasswordResetTokenValid).
rpc CheckPasswordResetToken(CheckPasswordResetTokenRequest) returns (CheckPasswordResetTokenResponse);
// ResetPassword replaces Handlers.ResetPassword. Also revokes every
// session for the account (db.UpdatePassword's cascade delete).
rpc ResetPassword(ResetPasswordRequest) returns (ResetPasswordResponse);
// GetUser is used by other services (e.g. billing, when it only has a
// user_id) to resolve an email address. Never returns PasswordHash.
rpc GetUser(GetUserRequest) returns (User);
}
message User {
int64 id = 1;
string email = 2;
bool email_verified = 3;
google.protobuf.Timestamp created_at = 4;
}
message SignUpRequest {
string email = 1;
string password = 2;
string password_confirm = 3;
string client_ip = 4; // for the shared signup/resend rate limiter
}
message SignUpResponse {
// Always true unless a hard infrastructure error occurred — the
// monolith never reveals whether the address was already registered.
bool check_inbox = 1;
string email = 2;
// mail_configured is echoed back so the gateway can show the same
// "written to the server log instead" notice the monolith shows.
bool mail_configured = 3;
}
message ResendVerificationRequest {
string email = 1;
string client_ip = 2;
}
message ResendVerificationResponse {
bool mail_configured = 1;
}
message VerifyEmailRequest {
string email = 1;
string code = 2;
string client_ip = 3;
}
message VerifyEmailResponse {
bool verified = 1;
}
message LoginRequest {
string email = 1;
string password = 2;
string client_ip = 3;
}
message LoginResponse {
bool ok = 1;
// Only one of these is meaningful depending on `ok` / `needs_verification`.
bool needs_verification = 2;
string session_token = 3; // raw token; gateway stores only this, in a cookie
google.protobuf.Timestamp session_expires_at = 4;
User user = 5;
}
message LogoutRequest {
string session_token = 1;
}
message LogoutResponse {}
message ValidateSessionRequest {
string session_token = 1;
}
message ValidateSessionResponse {
bool valid = 1;
User user = 2;
}
message ForgotPasswordRequest {
string email = 1;
string client_ip = 2;
}
message ForgotPasswordResponse {}
message CheckPasswordResetTokenRequest {
string token = 1;
}
message CheckPasswordResetTokenResponse {
bool valid = 1;
}
message ResetPasswordRequest {
string token = 1;
string password = 2;
string password_confirm = 3;
}
message ResetPasswordResponse {
bool ok = 1;
}
message GetUserRequest {
int64 user_id = 1;
}

View File

@@ -0,0 +1,75 @@
// Package knowledge is the Code Intelligence (RAG) bounded context:
// chunking, embedding (via the knowledge service's vLLM-backed embed client) and
// cosine-similarity search over workspace source files. System of
// record for the pgvector-backed `documents` table (see
// services/knowledge/migrations/0001_init.sql).
//
// Mirrors internal/rag/*.go. IndexDirectory (cmd/indexer) now walks the
// workspace through the workspace service instead of the local
// filesystem — see tools/indexer.
syntax = "proto3";
package enkly.knowledge.v1;
option go_package = "enkly/gen/knowledge/v1;knowledgev1";
service KnowledgeService {
// IndexFile replaces rag.Store.IndexFile: chunks the given file
// content, embeds each new/changed chunk, and upserts it.
rpc IndexFile(IndexFileRequest) returns (IndexFileResponse);
// PruneMissing replaces rag.Store.pruneMissing, called by the indexer
// after a full directory walk to drop rows for files no longer
// present.
rpc PruneMissing(PruneMissingRequest) returns (PruneMissingResponse);
// Search replaces rag.Store.Search — the search_codebase agent tool.
rpc Search(SearchRequest) returns (SearchResponse);
// ListIndexedFiles replaces db.ListIndexedFiles, used by the gateway's
// sidebar "indexed files" panel (Handlers.renderConversation).
rpc ListIndexedFiles(ListIndexedFilesRequest) returns (ListIndexedFilesResponse);
}
message CodeChunk {
int64 id = 1;
string file_path = 2;
int32 chunk_index = 3;
string content = 4;
double distance = 5; // cosine distance; lower = more relevant
}
message IndexFileRequest {
string file_path = 1;
string content = 2;
}
message IndexFileResponse {
int32 chunks_indexed = 1;
int32 chunks_skipped = 2; // unchanged content_hash
}
message PruneMissingRequest {
repeated string keep_file_paths = 1;
}
message PruneMissingResponse {
int32 rows_deleted = 1;
}
message SearchRequest {
string query = 1;
int32 top_k = 2;
}
message SearchResponse {
repeated CodeChunk chunks = 1;
}
message ListIndexedFilesRequest {
int32 limit = 1; // 0 = default (200)
}
message ListIndexedFilesResponse {
repeated string file_paths = 1;
}

View File

@@ -0,0 +1,196 @@
// Package workspace is the Workspace (agent sandbox / files) bounded
// context: the single shared ENKLY_WORKSPACE directory the coding agent
// reads, writes and runs commands in, and that the "Files" panel in the
// UI lets a human upload into / download from. Every path-jailing rule
// from internal/llm/sandbox.go (ResolvePath, the bash allowlist, the
// filesystem guards) is enforced here and nowhere else — no other
// service is allowed to touch the workspace volume directly.
//
// Mirrors internal/llm/tools.go (filesystem + bash tools) and
// internal/handlers/files.go / ui.go (upload, download, tree).
syntax = "proto3";
package enkly.workspace.v1;
option go_package = "enkly/gen/workspace/v1;workspacev1";
import "google/protobuf/timestamp.proto";
service WorkspaceService {
// --- agent filesystem/bash tools (internal/llm/tools.go, sandbox.go) ---
rpc ReadFile(ReadFileRequest) returns (ReadFileResponse);
rpc WriteFile(WriteFileRequest) returns (WriteFileResponse);
rpc EditFile(EditFileRequest) returns (EditFileResponse);
rpc MultiEditFile(MultiEditFileRequest) returns (MultiEditFileResponse);
rpc ListDirectory(ListDirectoryRequest) returns (ListDirectoryResponse);
rpc GlobSearch(GlobSearchRequest) returns (GlobSearchResponse);
rpc Grep(GrepRequest) returns (GrepResponse);
rpc RunBashCommand(RunBashCommandRequest) returns (RunBashCommandResponse);
// --- human-facing files panel (internal/handlers/files.go, ui.go) ---
rpc UploadFile(UploadFileRequest) returns (UploadFileResponse);
rpc DownloadFile(DownloadFileRequest) returns (DownloadFileResponse);
rpc Tree(TreeRequest) returns (TreeResponse);
rpc StatFile(StatFileRequest) returns (StatFileResponse); // backs deliverable cards
}
message ReadFileRequest {
string path = 1;
int32 offset = 2; // 0-based line offset
int32 limit = 3; // 0 = default (maxReadLines)
}
message ReadFileResponse {
string content = 1;
bool truncated = 2;
bool is_error = 3;
string error_message = 4;
}
message WriteFileRequest {
string path = 1;
string content = 2;
}
message WriteFileResponse {
int64 bytes_written = 1;
bool is_error = 2;
string error_message = 3;
}
message EditFileRequest {
string path = 1;
string old_string = 2;
string new_string = 3;
bool replace_all = 4;
}
message EditFileResponse {
string diff_preview = 1;
int32 occurrences = 2;
bool is_error = 3;
string error_message = 4;
}
message TextEdit {
string old_string = 1;
string new_string = 2;
bool replace_all = 3;
}
message MultiEditFileRequest {
string path = 1;
repeated TextEdit edits = 2;
}
message MultiEditFileResponse {
string diff_preview = 1;
bool is_error = 2;
string error_message = 3;
}
message ListDirectoryRequest {
string path = 1;
}
message ListDirectoryResponse {
string listing = 1; // pre-rendered, matching the monolith's tool output format
bool is_error = 2;
string error_message = 3;
}
message GlobSearchRequest {
string pattern = 1;
string path = 2;
}
message GlobSearchResponse {
// Pre-rendered exactly like every other tool's text output (a
// newline-separated path list, "no files matched that pattern", or a
// truncation notice) — deliberately NOT a repeated string, since that
// would require guessing which lines are paths vs. a status message,
// a lossy transformation the original tool output never needed.
string output = 1;
bool is_error = 2;
string error_message = 3;
}
message GrepRequest {
string pattern = 1;
string path = 2;
string glob = 3;
bool case_insensitive = 4;
}
message GrepResponse {
string output = 1;
bool is_error = 2;
string error_message = 3;
}
message RunBashCommandRequest {
string command = 1;
int32 timeout_seconds = 2; // 0 = service default
}
message RunBashCommandResponse {
string output = 1;
int32 exit_code = 2;
bool is_error = 3;
string error_message = 4;
}
message UploadFileRequest {
string path = 1; // destination relative path
bytes content = 2;
}
message UploadFileResponse {
int64 bytes_written = 1;
}
message DownloadFileRequest {
string path = 1;
}
message DownloadFileResponse {
bytes content = 1;
string filename = 2;
}
message TreeRequest {
string path = 1; // "" = workspace root
// one_level: list only the immediate children of path (both files
// and directories, directories included as entries in their own
// right) instead of recursing — backs the gateway's lazy-expanding
// file explorer panel (internal/handlers/ui.go's FileTree), where
// each folder click fetches just that folder. false (the default)
// recurses fully and returns only files, for the flat "Workspace
// files" upload/download panel (internal/handlers/files.go).
bool one_level = 2;
}
message TreeNode {
string name = 1;
string rel_path = 2;
bool is_dir = 3;
int64 size = 4;
google.protobuf.Timestamp modified_at = 5;
// download_url is left empty by this service — the gateway owns URL
// construction (it knows its own public route prefix), not workspace.
}
message TreeResponse {
repeated TreeNode nodes = 1;
}
message StatFileRequest {
string path = 1;
}
message StatFileResponse {
bool exists = 1;
bool is_dir = 2;
int64 size = 3;
google.protobuf.Timestamp modified_at = 4;
}

256
docker-compose.yml Normal file
View File

@@ -0,0 +1,256 @@
services:
# ---------------------------------------------------------------------
# Infrastructure
# ---------------------------------------------------------------------
rabbitmq:
image: rabbitmq:3.13-management-alpine
ports: ["5672:5672", "15672:15672"]
environment:
RABBITMQ_DEFAULT_USER: enkly
RABBITMQ_DEFAULT_PASS: enkly
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 10
# vLLM OpenAI-compatible inference servers. One container per model —
# a vLLM engine process serves exactly one model, so chat (Qwen,
# tool-calling + reasoning) and embeddings (Nomic) are two separate
# services with independent lifecycles, rather than the two "tags"
# one shared Ollama daemon used to serve. Both need an NVIDIA GPU and
# the nvidia container runtime (`docker info | grep nvidia` to check)
# — on a CPU-only dev box, run `docker compose --profile cpu up` isn't
# provided here because vLLM's CPU backend is a materially different
# build/image; see https://docs.vllm.ai for that path if you need it.
#
# Pin the image tag to a version you've actually tested — "latest" on
# a GPU inference server is how a routine `docker compose pull`
# becomes an unplanned model-serving outage. v0.31.0 below is this
# deployment's current pin (matches k8s/11-vllm.yaml); bump
# deliberately. Must stay >= the first tag with Qwen3.5 model
# support or the server fails to load with an unrecognized
# architecture error.
vllm-chat:
image: vllm/vllm-openai:v0.31.0
command:
- --model=Qwen/Qwen3.5-35B-A3B-GPTQ-Int4
- --served-model-name=${ENKLY_CHAT_MODEL:-qwen3.5}
- --quantization=gptq_marlin
- --tool-call-parser=qwen3_coder
- --enable-auto-tool-choice
- --reasoning-parser=qwen3
- --max-model-len=32768
- --gpu-memory-utilization=0.90
- --tensor-parallel-size=${ENKLY_CHAT_TP_SIZE:-1}
- --api-key=${ENKLY_VLLM_API_KEY:-}
ports: ["8000:8000"]
volumes: ["hf-cache:/root/.cache/huggingface"]
environment:
HUGGING_FACE_HUB_TOKEN: "${HUGGING_FACE_HUB_TOKEN:-}"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: ${ENKLY_CHAT_TP_SIZE:-1}
capabilities: [gpu]
# vLLM's own /dev/shm requirement for NCCL/CUDA-graph scratch space
# — without this, multi-GPU (tensor-parallel) startup or even
# single-GPU CUDA-graph capture can fail or silently fall back to a
# slower path. 2gb is generous for a single-GPU chat replica; raise
# it if ENKLY_CHAT_TP_SIZE > 1.
shm_size: "2gb"
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"]
# Model load (Qwen3.5-35B-A3B-GPTQ-Int4, ~20GB on disk) can still
# take a few minutes from a cold Hugging Face cache — a short
# interval just means more log noise while it warms up, not a
# faster start.
interval: 15s
timeout: 5s
retries: 40
start_period: 10m
vllm-embed:
image: vllm/vllm-openai:v0.6.3
command:
- --model=nomic-ai/nomic-embed-text-v1.5
- --served-model-name=${ENKLY_EMBED_MODEL:-nomic-embed-text}
- --task=embed
- --trust-remote-code
- --gpu-memory-utilization=0.30
ports: ["8001:8000"]
volumes: ["hf-cache:/root/.cache/huggingface"]
environment:
HUGGING_FACE_HUB_TOKEN: "${HUGGING_FACE_HUB_TOKEN:-}"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
shm_size: "1gb"
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"]
interval: 10s
timeout: 5s
retries: 30
start_period: 3m
# Database-per-service: four separate Postgres databases, never one
# shared instance a service could accidentally cross-query. knowledge
# uses the pgvector image; the other three a bare postgres image.
iam-db:
image: postgres:16-alpine
environment: {POSTGRES_USER: enkly, POSTGRES_PASSWORD: enkly, POSTGRES_DB: enkly_iam}
volumes: ["iam-db-data:/var/lib/postgresql/data"]
healthcheck: {test: ["CMD-SHELL", "pg_isready -U enkly"], interval: 5s, timeout: 5s, retries: 10}
billing-db:
image: postgres:16-alpine
environment: {POSTGRES_USER: enkly, POSTGRES_PASSWORD: enkly, POSTGRES_DB: enkly_billing}
volumes: ["billing-db-data:/var/lib/postgresql/data"]
healthcheck: {test: ["CMD-SHELL", "pg_isready -U enkly"], interval: 5s, timeout: 5s, retries: 10}
conversation-db:
image: postgres:16-alpine
environment: {POSTGRES_USER: enkly, POSTGRES_PASSWORD: enkly, POSTGRES_DB: enkly_conversation}
volumes: ["conversation-db-data:/var/lib/postgresql/data"]
healthcheck: {test: ["CMD-SHELL", "pg_isready -U enkly"], interval: 5s, timeout: 5s, retries: 10}
knowledge-db:
image: pgvector/pgvector:pg16
environment: {POSTGRES_USER: enkly, POSTGRES_PASSWORD: enkly, POSTGRES_DB: enkly_knowledge}
volumes: ["knowledge-db-data:/var/lib/postgresql/data"]
healthcheck: {test: ["CMD-SHELL", "pg_isready -U enkly"], interval: 5s, timeout: 5s, retries: 10}
# ---------------------------------------------------------------------
# Application services
# ---------------------------------------------------------------------
iam:
build: {context: ., dockerfile: services/iam/Dockerfile}
depends_on:
iam-db: {condition: service_healthy}
rabbitmq: {condition: service_healthy}
environment:
ENKLY_IAM_GRPC_ADDR: ":9001"
ENKLY_IAM_POSTGRES_DSN: "postgres://enkly:enkly@iam-db:5432/enkly_iam?sslmode=disable"
ENKLY_RABBITMQ_URL: "amqp://enkly:enkly@rabbitmq:5672/"
ENKLY_BASE_URL: "${ENKLY_BASE_URL:-http://localhost:8080}"
ENKLY_SMTP_USERNAME: "${ENKLY_SMTP_USERNAME:-}"
ENKLY_SMTP_PASSWORD: "${ENKLY_SMTP_PASSWORD:-}"
restart: unless-stopped
billing:
build: {context: ., dockerfile: services/billing/Dockerfile}
depends_on:
billing-db: {condition: service_healthy}
rabbitmq: {condition: service_healthy}
environment:
ENKLY_BILLING_GRPC_ADDR: ":9002"
ENKLY_BILLING_POSTGRES_DSN: "postgres://enkly:enkly@billing-db:5432/enkly_billing?sslmode=disable"
ENKLY_RABBITMQ_URL: "amqp://enkly:enkly@rabbitmq:5672/"
STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:-}"
STRIPE_PUBLISH_KEY: "${STRIPE_PUBLISH_KEY:-}"
STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}"
STRIPE_PRICE_BASIC: "${STRIPE_PRICE_BASIC:-}"
STRIPE_PRICE_PRO: "${STRIPE_PRICE_PRO:-}"
STRIPE_PRICE_PREMIUM: "${STRIPE_PRICE_PREMIUM:-}"
restart: unless-stopped
conversation:
build: {context: ., dockerfile: services/conversation/Dockerfile}
depends_on:
conversation-db: {condition: service_healthy}
rabbitmq: {condition: service_healthy}
environment:
ENKLY_CONVERSATION_GRPC_ADDR: ":9003"
ENKLY_CONVERSATION_POSTGRES_DSN: "postgres://enkly:enkly@conversation-db:5432/enkly_conversation?sslmode=disable"
ENKLY_RABBITMQ_URL: "amqp://enkly:enkly@rabbitmq:5672/"
restart: unless-stopped
workspace:
build: {context: ., dockerfile: services/workspace/Dockerfile}
volumes: ["workspace-data:/data/workspace"]
environment:
ENKLY_WORKSPACE_GRPC_ADDR: ":9004"
ENKLY_WORKSPACE: "/data/workspace"
ENKLY_BASH_TIMEOUT_SECONDS: "${ENKLY_BASH_TIMEOUT_SECONDS:-0}"
ENKLY_BASH_ALLOWLIST: "${ENKLY_BASH_ALLOWLIST:-}"
restart: unless-stopped
knowledge:
build: {context: ., dockerfile: services/knowledge/Dockerfile}
depends_on:
knowledge-db: {condition: service_healthy}
vllm-embed: {condition: service_healthy}
environment:
ENKLY_KNOWLEDGE_GRPC_ADDR: ":9005"
ENKLY_KNOWLEDGE_POSTGRES_DSN: "postgres://enkly:enkly@knowledge-db:5432/enkly_knowledge?sslmode=disable"
ENKLY_VLLM_EMBED_URL: "https://vllm-embed.enkly.org/v1"
ENKLY_EMBED_MODEL: "${ENKLY_EMBED_MODEL:-nomic-embed-text}"
ENKLY_VLLM_API_KEY: "${ENKLY_VLLM_API_KEY:-}"
restart: unless-stopped
agent:
build: {context: ., dockerfile: services/agent/Dockerfile}
depends_on:
workspace: {condition: service_started}
knowledge: {condition: service_started}
billing: {condition: service_started}
conversation: {condition: service_started}
vllm-chat: {condition: service_healthy}
vllm-embed: {condition: service_healthy}
environment:
ENKLY_AGENT_GRPC_ADDR: ":9006"
ENKLY_WORKSPACE_GRPC_ADDR: "workspace:9004"
ENKLY_KNOWLEDGE_GRPC_ADDR: "knowledge:9005"
ENKLY_BILLING_GRPC_ADDR: "billing:9002"
ENKLY_CONVERSATION_GRPC_ADDR: "conversation:9003"
ENKLY_VLLM_CHAT_URL: "https://vllm-chat.enkly.org/v1"
ENKLY_VLLM_EMBED_URL: "https://vllm-embed.enkly.org/v1"
ENKLY_CHAT_MODEL: "${ENKLY_CHAT_MODEL:-qwen3.5}"
ENKLY_EMBED_MODEL: "${ENKLY_EMBED_MODEL:-nomic-embed-text}"
ENKLY_VLLM_API_KEY: "${ENKLY_VLLM_API_KEY:-}"
restart: unless-stopped
notification:
build: {context: ., dockerfile: services/notification/Dockerfile}
depends_on:
rabbitmq: {condition: service_healthy}
environment:
ENKLY_RABBITMQ_URL: "amqp://enkly:enkly@rabbitmq:5672/"
ENKLY_SMTP_HOST: "${ENKLY_SMTP_HOST:-smtp.gmail.com}"
ENKLY_SMTP_PORT: "${ENKLY_SMTP_PORT:-587}"
ENKLY_SMTP_USERNAME: "${ENKLY_SMTP_USERNAME:-}"
ENKLY_SMTP_PASSWORD: "${ENKLY_SMTP_PASSWORD:-}"
ENKLY_SMTP_FROM: "${ENKLY_SMTP_FROM:-}"
restart: unless-stopped
gateway:
build: {context: ., dockerfile: services/gateway/Dockerfile}
depends_on: [iam, billing, conversation, workspace, knowledge, agent]
ports: ["8080:8080"]
environment:
ENKLY_GATEWAY_HTTP_ADDR: ":8080"
ENKLY_BASE_URL: "${ENKLY_BASE_URL:-http://localhost:8080}"
ENKLY_IAM_GRPC_ADDR: "iam:9001"
ENKLY_BILLING_GRPC_ADDR: "billing:9002"
ENKLY_CONVERSATION_GRPC_ADDR: "conversation:9003"
ENKLY_WORKSPACE_GRPC_ADDR: "workspace:9004"
ENKLY_KNOWLEDGE_GRPC_ADDR: "knowledge:9005"
ENKLY_AGENT_GRPC_ADDR: "agent:9006"
STRIPE_PUBLISH_KEY: "${STRIPE_PUBLISH_KEY:-}"
restart: unless-stopped
volumes:
hf-cache:
iam-db-data:
billing-db-data:
conversation-db-data:
knowledge-db-data:
workspace-data:

999
gen/agent/v1/agent.pb.go Normal file
View File

@@ -0,0 +1,999 @@
// Package agent is the Agent Orchestration bounded context: the agentic
// run loop (prompt -> vLLM -> tool calls -> final reply), the chat-run
// quota/concurrency guard, and the in-flight/just-finished run registry
// that used to live in internal/handlers/chat.go's StreamManager.
//
// This is a saga coordinator, not a data owner: it holds no SQL table of
// its own. It calls billing (usage quota), conversation (persisting
// turns) and knowledge (the search_codebase tool) over gRPC, and
// workspace (every filesystem/bash tool) over gRPC, exactly replacing
// internal/llm/agent.go + internal/llm/tools.go's direct, in-process
// calls into internal/db and the local filesystem.
//
// Rendering (Markdown, syntax highlighting, deliverable cards) stays out
// of this service on purpose — see internal/render's doc comment in the
// gateway service — so this API deals in plain text and structured tool
// results, never HTML.
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.34.2
// protoc v3.21.12
// source: agent/v1/agent.proto
package agentv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type StartTurnOutcome int32
const (
StartTurnOutcome_START_TURN_OUTCOME_UNSPECIFIED StartTurnOutcome = 0
StartTurnOutcome_START_TURN_OUTCOME_OK StartTurnOutcome = 1
StartTurnOutcome_START_TURN_OUTCOME_CONVERSATION_NOT_FOUND StartTurnOutcome = 2
StartTurnOutcome_START_TURN_OUTCOME_CONVERSATION_BUSY StartTurnOutcome = 3
StartTurnOutcome_START_TURN_OUTCOME_TOO_MANY_STREAMS StartTurnOutcome = 4
StartTurnOutcome_START_TURN_OUTCOME_RATE_LIMITED StartTurnOutcome = 5
StartTurnOutcome_START_TURN_OUTCOME_QUOTA_EXCEEDED StartTurnOutcome = 6
StartTurnOutcome_START_TURN_OUTCOME_INTERNAL_ERROR StartTurnOutcome = 7
)
// Enum value maps for StartTurnOutcome.
var (
StartTurnOutcome_name = map[int32]string{
0: "START_TURN_OUTCOME_UNSPECIFIED",
1: "START_TURN_OUTCOME_OK",
2: "START_TURN_OUTCOME_CONVERSATION_NOT_FOUND",
3: "START_TURN_OUTCOME_CONVERSATION_BUSY",
4: "START_TURN_OUTCOME_TOO_MANY_STREAMS",
5: "START_TURN_OUTCOME_RATE_LIMITED",
6: "START_TURN_OUTCOME_QUOTA_EXCEEDED",
7: "START_TURN_OUTCOME_INTERNAL_ERROR",
}
StartTurnOutcome_value = map[string]int32{
"START_TURN_OUTCOME_UNSPECIFIED": 0,
"START_TURN_OUTCOME_OK": 1,
"START_TURN_OUTCOME_CONVERSATION_NOT_FOUND": 2,
"START_TURN_OUTCOME_CONVERSATION_BUSY": 3,
"START_TURN_OUTCOME_TOO_MANY_STREAMS": 4,
"START_TURN_OUTCOME_RATE_LIMITED": 5,
"START_TURN_OUTCOME_QUOTA_EXCEEDED": 6,
"START_TURN_OUTCOME_INTERNAL_ERROR": 7,
}
)
func (x StartTurnOutcome) Enum() *StartTurnOutcome {
p := new(StartTurnOutcome)
*p = x
return p
}
func (x StartTurnOutcome) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (StartTurnOutcome) Descriptor() protoreflect.EnumDescriptor {
return file_agent_v1_agent_proto_enumTypes[0].Descriptor()
}
func (StartTurnOutcome) Type() protoreflect.EnumType {
return &file_agent_v1_agent_proto_enumTypes[0]
}
func (x StartTurnOutcome) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use StartTurnOutcome.Descriptor instead.
func (StartTurnOutcome) EnumDescriptor() ([]byte, []int) {
return file_agent_v1_agent_proto_rawDescGZIP(), []int{0}
}
type StartTurnRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
ConversationId int64 `protobuf:"varint,2,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"`
Prompt string `protobuf:"bytes,3,opt,name=prompt,proto3" json:"prompt,omitempty"`
}
func (x *StartTurnRequest) Reset() {
*x = StartTurnRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_v1_agent_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *StartTurnRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StartTurnRequest) ProtoMessage() {}
func (x *StartTurnRequest) ProtoReflect() protoreflect.Message {
mi := &file_agent_v1_agent_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StartTurnRequest.ProtoReflect.Descriptor instead.
func (*StartTurnRequest) Descriptor() ([]byte, []int) {
return file_agent_v1_agent_proto_rawDescGZIP(), []int{0}
}
func (x *StartTurnRequest) GetUserId() int64 {
if x != nil {
return x.UserId
}
return 0
}
func (x *StartTurnRequest) GetConversationId() int64 {
if x != nil {
return x.ConversationId
}
return 0
}
func (x *StartTurnRequest) GetPrompt() string {
if x != nil {
return x.Prompt
}
return ""
}
type StartTurnResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Outcome StartTurnOutcome `protobuf:"varint,1,opt,name=outcome,proto3,enum=enkly.agent.v1.StartTurnOutcome" json:"outcome,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // user-facing text for any non-OK outcome
StreamId string `protobuf:"bytes,3,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` // set when outcome == OK
}
func (x *StartTurnResponse) Reset() {
*x = StartTurnResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_v1_agent_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *StartTurnResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StartTurnResponse) ProtoMessage() {}
func (x *StartTurnResponse) ProtoReflect() protoreflect.Message {
mi := &file_agent_v1_agent_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StartTurnResponse.ProtoReflect.Descriptor instead.
func (*StartTurnResponse) Descriptor() ([]byte, []int) {
return file_agent_v1_agent_proto_rawDescGZIP(), []int{1}
}
func (x *StartTurnResponse) GetOutcome() StartTurnOutcome {
if x != nil {
return x.Outcome
}
return StartTurnOutcome_START_TURN_OUTCOME_UNSPECIFIED
}
func (x *StartTurnResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *StartTurnResponse) GetStreamId() string {
if x != nil {
return x.StreamId
}
return ""
}
type StreamTurnRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
StreamId string `protobuf:"bytes,2,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"`
ResumeFromToolIndex int32 `protobuf:"varint,3,opt,name=resume_from_tool_index,json=resumeFromToolIndex,proto3" json:"resume_from_tool_index,omitempty"`
}
func (x *StreamTurnRequest) Reset() {
*x = StreamTurnRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_v1_agent_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *StreamTurnRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamTurnRequest) ProtoMessage() {}
func (x *StreamTurnRequest) ProtoReflect() protoreflect.Message {
mi := &file_agent_v1_agent_proto_msgTypes[2]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamTurnRequest.ProtoReflect.Descriptor instead.
func (*StreamTurnRequest) Descriptor() ([]byte, []int) {
return file_agent_v1_agent_proto_rawDescGZIP(), []int{2}
}
func (x *StreamTurnRequest) GetUserId() int64 {
if x != nil {
return x.UserId
}
return 0
}
func (x *StreamTurnRequest) GetStreamId() string {
if x != nil {
return x.StreamId
}
return ""
}
func (x *StreamTurnRequest) GetResumeFromToolIndex() int32 {
if x != nil {
return x.ResumeFromToolIndex
}
return 0
}
type ToolCall struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
ArgumentsJson string `protobuf:"bytes,2,opt,name=arguments_json,json=argumentsJson,proto3" json:"arguments_json,omitempty"`
Result string `protobuf:"bytes,3,opt,name=result,proto3" json:"result,omitempty"`
IsError bool `protobuf:"varint,4,opt,name=is_error,json=isError,proto3" json:"is_error,omitempty"`
}
func (x *ToolCall) Reset() {
*x = ToolCall{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_v1_agent_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ToolCall) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ToolCall) ProtoMessage() {}
func (x *ToolCall) ProtoReflect() protoreflect.Message {
mi := &file_agent_v1_agent_proto_msgTypes[3]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ToolCall.ProtoReflect.Descriptor instead.
func (*ToolCall) Descriptor() ([]byte, []int) {
return file_agent_v1_agent_proto_rawDescGZIP(), []int{3}
}
func (x *ToolCall) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *ToolCall) GetArgumentsJson() string {
if x != nil {
return x.ArgumentsJson
}
return ""
}
func (x *ToolCall) GetResult() string {
if x != nil {
return x.Result
}
return ""
}
func (x *ToolCall) GetIsError() bool {
if x != nil {
return x.IsError
}
return false
}
// ThinkingRound is one agent round's own reasoning trace, standalone
// rather than folded into the running total thinking_delta carries —
// the same per-step granularity Claude's interleaved thinking exposes
// (one distinct thinking block per tool-use step) rather than one
// cumulative blob for the whole turn. round is 0-indexed and matches
// the round the agent service's internal run loop was on when this
// trace was produced; a client can use it to render reasoning as
// discrete, collapsible steps next to the tool call each one preceded,
// instead of a single growing "thinking" pane.
type ThinkingRound struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Round int32 `protobuf:"varint,1,opt,name=round,proto3" json:"round,omitempty"`
Thinking string `protobuf:"bytes,2,opt,name=thinking,proto3" json:"thinking,omitempty"`
}
func (x *ThinkingRound) Reset() {
*x = ThinkingRound{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_v1_agent_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ThinkingRound) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ThinkingRound) ProtoMessage() {}
func (x *ThinkingRound) ProtoReflect() protoreflect.Message {
mi := &file_agent_v1_agent_proto_msgTypes[4]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ThinkingRound.ProtoReflect.Descriptor instead.
func (*ThinkingRound) Descriptor() ([]byte, []int) {
return file_agent_v1_agent_proto_rawDescGZIP(), []int{4}
}
func (x *ThinkingRound) GetRound() int32 {
if x != nil {
return x.Round
}
return 0
}
func (x *ThinkingRound) GetThinking() string {
if x != nil {
return x.Thinking
}
return ""
}
type TurnEvent struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Types that are assignable to Event:
//
// *TurnEvent_ThinkingDelta
// *TurnEvent_TokenDelta
// *TurnEvent_ToolCall
// *TurnEvent_Done
// *TurnEvent_ThinkingRound
Event isTurnEvent_Event `protobuf_oneof:"event"`
}
func (x *TurnEvent) Reset() {
*x = TurnEvent{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_v1_agent_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *TurnEvent) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TurnEvent) ProtoMessage() {}
func (x *TurnEvent) ProtoReflect() protoreflect.Message {
mi := &file_agent_v1_agent_proto_msgTypes[5]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TurnEvent.ProtoReflect.Descriptor instead.
func (*TurnEvent) Descriptor() ([]byte, []int) {
return file_agent_v1_agent_proto_rawDescGZIP(), []int{5}
}
func (m *TurnEvent) GetEvent() isTurnEvent_Event {
if m != nil {
return m.Event
}
return nil
}
func (x *TurnEvent) GetThinkingDelta() string {
if x, ok := x.GetEvent().(*TurnEvent_ThinkingDelta); ok {
return x.ThinkingDelta
}
return ""
}
func (x *TurnEvent) GetTokenDelta() string {
if x, ok := x.GetEvent().(*TurnEvent_TokenDelta); ok {
return x.TokenDelta
}
return ""
}
func (x *TurnEvent) GetToolCall() *ToolCall {
if x, ok := x.GetEvent().(*TurnEvent_ToolCall); ok {
return x.ToolCall
}
return nil
}
func (x *TurnEvent) GetDone() *TurnDone {
if x, ok := x.GetEvent().(*TurnEvent_Done); ok {
return x.Done
}
return nil
}
func (x *TurnEvent) GetThinkingRound() *ThinkingRound {
if x, ok := x.GetEvent().(*TurnEvent_ThinkingRound); ok {
return x.ThinkingRound
}
return nil
}
type isTurnEvent_Event interface {
isTurnEvent_Event()
}
type TurnEvent_ThinkingDelta struct {
ThinkingDelta string `protobuf:"bytes,1,opt,name=thinking_delta,json=thinkingDelta,proto3,oneof"` // cumulative reasoning text so far, plain
}
type TurnEvent_TokenDelta struct {
TokenDelta string `protobuf:"bytes,2,opt,name=token_delta,json=tokenDelta,proto3,oneof"` // cumulative reply text so far, plain
}
type TurnEvent_ToolCall struct {
ToolCall *ToolCall `protobuf:"bytes,3,opt,name=tool_call,json=toolCall,proto3,oneof"` // one completed tool call
}
type TurnEvent_Done struct {
Done *TurnDone `protobuf:"bytes,4,opt,name=done,proto3,oneof"`
}
type TurnEvent_ThinkingRound struct {
ThinkingRound *ThinkingRound `protobuf:"bytes,5,opt,name=thinking_round,json=thinkingRound,proto3,oneof"` // one round's own reasoning trace, standalone
}
func (*TurnEvent_ThinkingDelta) isTurnEvent_Event() {}
func (*TurnEvent_TokenDelta) isTurnEvent_Event() {}
func (*TurnEvent_ToolCall) isTurnEvent_Event() {}
func (*TurnEvent_Done) isTurnEvent_Event() {}
func (*TurnEvent_ThinkingRound) isTurnEvent_Event() {}
type TurnDone struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
FinalText string `protobuf:"bytes,1,opt,name=final_text,json=finalText,proto3" json:"final_text,omitempty"` // plain Markdown source, not yet rendered
// every tool call made during the turn, successful or not — the
// gateway uses this to build deliverable (download) cards exactly
// like internal/handlers/deliverables.go.attachDeliverables.
ToolCalls []*ToolCall `protobuf:"bytes,2,rep,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"`
}
func (x *TurnDone) Reset() {
*x = TurnDone{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_v1_agent_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *TurnDone) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TurnDone) ProtoMessage() {}
func (x *TurnDone) ProtoReflect() protoreflect.Message {
mi := &file_agent_v1_agent_proto_msgTypes[6]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TurnDone.ProtoReflect.Descriptor instead.
func (*TurnDone) Descriptor() ([]byte, []int) {
return file_agent_v1_agent_proto_rawDescGZIP(), []int{6}
}
func (x *TurnDone) GetFinalText() string {
if x != nil {
return x.FinalText
}
return ""
}
func (x *TurnDone) GetToolCalls() []*ToolCall {
if x != nil {
return x.ToolCalls
}
return nil
}
type StopTurnRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
StreamId string `protobuf:"bytes,2,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"`
}
func (x *StopTurnRequest) Reset() {
*x = StopTurnRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_v1_agent_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *StopTurnRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StopTurnRequest) ProtoMessage() {}
func (x *StopTurnRequest) ProtoReflect() protoreflect.Message {
mi := &file_agent_v1_agent_proto_msgTypes[7]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StopTurnRequest.ProtoReflect.Descriptor instead.
func (*StopTurnRequest) Descriptor() ([]byte, []int) {
return file_agent_v1_agent_proto_rawDescGZIP(), []int{7}
}
func (x *StopTurnRequest) GetUserId() int64 {
if x != nil {
return x.UserId
}
return 0
}
func (x *StopTurnRequest) GetStreamId() string {
if x != nil {
return x.StreamId
}
return ""
}
type StopTurnResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *StopTurnResponse) Reset() {
*x = StopTurnResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_v1_agent_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *StopTurnResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StopTurnResponse) ProtoMessage() {}
func (x *StopTurnResponse) ProtoReflect() protoreflect.Message {
mi := &file_agent_v1_agent_proto_msgTypes[8]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StopTurnResponse.ProtoReflect.Descriptor instead.
func (*StopTurnResponse) Descriptor() ([]byte, []int) {
return file_agent_v1_agent_proto_rawDescGZIP(), []int{8}
}
var File_agent_v1_agent_proto protoreflect.FileDescriptor
var file_agent_v1_agent_proto_rawDesc = []byte{
0x0a, 0x14, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x22, 0x6c, 0x0a, 0x10, 0x53, 0x74, 0x61, 0x72, 0x74, 0x54,
0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x0f, 0x63, 0x6f, 0x6e, 0x76, 0x65, 0x72, 0x73, 0x61, 0x74,
0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x63, 0x6f,
0x6e, 0x76, 0x65, 0x72, 0x73, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06,
0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72,
0x6f, 0x6d, 0x70, 0x74, 0x22, 0x86, 0x01, 0x0a, 0x11, 0x53, 0x74, 0x61, 0x72, 0x74, 0x54, 0x75,
0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3a, 0x0a, 0x07, 0x6f, 0x75,
0x74, 0x63, 0x6f, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x65, 0x6e,
0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x61,
0x72, 0x74, 0x54, 0x75, 0x72, 0x6e, 0x4f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x52, 0x07, 0x6f,
0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20,
0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x22, 0x7e, 0x0a,
0x11, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73,
0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x33, 0x0a, 0x16, 0x72, 0x65, 0x73, 0x75,
0x6d, 0x65, 0x5f, 0x66, 0x72, 0x6f, 0x6d, 0x5f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x6e, 0x64,
0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x13, 0x72, 0x65, 0x73, 0x75, 0x6d, 0x65,
0x46, 0x72, 0x6f, 0x6d, 0x54, 0x6f, 0x6f, 0x6c, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x22, 0x78, 0x0a,
0x08, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x25, 0x0a,
0x0e, 0x61, 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x72, 0x67, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73,
0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x03,
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x19, 0x0a, 0x08,
0x69, 0x73, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07,
0x69, 0x73, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x41, 0x0a, 0x0d, 0x54, 0x68, 0x69, 0x6e, 0x6b,
0x69, 0x6e, 0x67, 0x52, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, 0x75, 0x6e,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x1a,
0x0a, 0x08, 0x74, 0x68, 0x69, 0x6e, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
0x52, 0x08, 0x74, 0x68, 0x69, 0x6e, 0x6b, 0x69, 0x6e, 0x67, 0x22, 0x91, 0x02, 0x0a, 0x09, 0x54,
0x75, 0x72, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0e, 0x74, 0x68, 0x69, 0x6e,
0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x48, 0x00, 0x52, 0x0d, 0x74, 0x68, 0x69, 0x6e, 0x6b, 0x69, 0x6e, 0x67, 0x44, 0x65, 0x6c, 0x74,
0x61, 0x12, 0x21, 0x0a, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x64, 0x65, 0x6c, 0x74, 0x61,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x44,
0x65, 0x6c, 0x74, 0x61, 0x12, 0x37, 0x0a, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c,
0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c,
0x6c, 0x48, 0x00, 0x52, 0x08, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x2e, 0x0a,
0x04, 0x64, 0x6f, 0x6e, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x65, 0x6e,
0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x72,
0x6e, 0x44, 0x6f, 0x6e, 0x65, 0x48, 0x00, 0x52, 0x04, 0x64, 0x6f, 0x6e, 0x65, 0x12, 0x46, 0x0a,
0x0e, 0x74, 0x68, 0x69, 0x6e, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x18,
0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x68, 0x69, 0x6e, 0x6b, 0x69, 0x6e, 0x67, 0x52,
0x6f, 0x75, 0x6e, 0x64, 0x48, 0x00, 0x52, 0x0d, 0x74, 0x68, 0x69, 0x6e, 0x6b, 0x69, 0x6e, 0x67,
0x52, 0x6f, 0x75, 0x6e, 0x64, 0x42, 0x07, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x62,
0x0a, 0x08, 0x54, 0x75, 0x72, 0x6e, 0x44, 0x6f, 0x6e, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x66, 0x69,
0x6e, 0x61, 0x6c, 0x5f, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
0x66, 0x69, 0x6e, 0x61, 0x6c, 0x54, 0x65, 0x78, 0x74, 0x12, 0x37, 0x0a, 0x0a, 0x74, 0x6f, 0x6f,
0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e,
0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54,
0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x52, 0x09, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c,
0x6c, 0x73, 0x22, 0x47, 0x0a, 0x0f, 0x53, 0x74, 0x6f, 0x70, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b,
0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x22, 0x12, 0x0a, 0x10, 0x53,
0x74, 0x6f, 0x70, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a,
0xc6, 0x02, 0x0a, 0x10, 0x53, 0x74, 0x61, 0x72, 0x74, 0x54, 0x75, 0x72, 0x6e, 0x4f, 0x75, 0x74,
0x63, 0x6f, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x1e, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54, 0x55,
0x52, 0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45,
0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x53, 0x54, 0x41, 0x52,
0x54, 0x5f, 0x54, 0x55, 0x52, 0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x4f,
0x4b, 0x10, 0x01, 0x12, 0x2d, 0x0a, 0x29, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54, 0x55, 0x52,
0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x56, 0x45, 0x52,
0x53, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x46, 0x4f, 0x55, 0x4e, 0x44,
0x10, 0x02, 0x12, 0x28, 0x0a, 0x24, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54, 0x55, 0x52, 0x4e,
0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x56, 0x45, 0x52, 0x53,
0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x42, 0x55, 0x53, 0x59, 0x10, 0x03, 0x12, 0x27, 0x0a, 0x23,
0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54, 0x55, 0x52, 0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f,
0x4d, 0x45, 0x5f, 0x54, 0x4f, 0x4f, 0x5f, 0x4d, 0x41, 0x4e, 0x59, 0x5f, 0x53, 0x54, 0x52, 0x45,
0x41, 0x4d, 0x53, 0x10, 0x04, 0x12, 0x23, 0x0a, 0x1f, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54,
0x55, 0x52, 0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x52, 0x41, 0x54, 0x45,
0x5f, 0x4c, 0x49, 0x4d, 0x49, 0x54, 0x45, 0x44, 0x10, 0x05, 0x12, 0x25, 0x0a, 0x21, 0x53, 0x54,
0x41, 0x52, 0x54, 0x5f, 0x54, 0x55, 0x52, 0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45,
0x5f, 0x51, 0x55, 0x4f, 0x54, 0x41, 0x5f, 0x45, 0x58, 0x43, 0x45, 0x45, 0x44, 0x45, 0x44, 0x10,
0x06, 0x12, 0x25, 0x0a, 0x21, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54, 0x55, 0x52, 0x4e, 0x5f,
0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x49, 0x4e, 0x54, 0x45, 0x52, 0x4e, 0x41, 0x4c,
0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x07, 0x32, 0xfd, 0x01, 0x0a, 0x0c, 0x41, 0x67, 0x65,
0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x50, 0x0a, 0x09, 0x53, 0x74, 0x61,
0x72, 0x74, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x20, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x54, 0x75, 0x72,
0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x54,
0x75, 0x72, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x0a, 0x53,
0x74, 0x72, 0x65, 0x61, 0x6d, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x21, 0x2e, 0x65, 0x6e, 0x6b, 0x6c,
0x79, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61,
0x6d, 0x54, 0x75, 0x72, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x65,
0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75,
0x72, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, 0x4d, 0x0a, 0x08, 0x53, 0x74, 0x6f,
0x70, 0x54, 0x75, 0x72, 0x6e, 0x12, 0x1f, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x54, 0x75, 0x72, 0x6e, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x54, 0x75, 0x72, 0x6e,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x1c, 0x5a, 0x1a, 0x65, 0x6e, 0x6b, 0x6c,
0x79, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x76, 0x31, 0x3b, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_agent_v1_agent_proto_rawDescOnce sync.Once
file_agent_v1_agent_proto_rawDescData = file_agent_v1_agent_proto_rawDesc
)
func file_agent_v1_agent_proto_rawDescGZIP() []byte {
file_agent_v1_agent_proto_rawDescOnce.Do(func() {
file_agent_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(file_agent_v1_agent_proto_rawDescData)
})
return file_agent_v1_agent_proto_rawDescData
}
var file_agent_v1_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_agent_v1_agent_proto_goTypes = []any{
(StartTurnOutcome)(0), // 0: enkly.agent.v1.StartTurnOutcome
(*StartTurnRequest)(nil), // 1: enkly.agent.v1.StartTurnRequest
(*StartTurnResponse)(nil), // 2: enkly.agent.v1.StartTurnResponse
(*StreamTurnRequest)(nil), // 3: enkly.agent.v1.StreamTurnRequest
(*ToolCall)(nil), // 4: enkly.agent.v1.ToolCall
(*ThinkingRound)(nil), // 5: enkly.agent.v1.ThinkingRound
(*TurnEvent)(nil), // 6: enkly.agent.v1.TurnEvent
(*TurnDone)(nil), // 7: enkly.agent.v1.TurnDone
(*StopTurnRequest)(nil), // 8: enkly.agent.v1.StopTurnRequest
(*StopTurnResponse)(nil), // 9: enkly.agent.v1.StopTurnResponse
}
var file_agent_v1_agent_proto_depIdxs = []int32{
0, // 0: enkly.agent.v1.StartTurnResponse.outcome:type_name -> enkly.agent.v1.StartTurnOutcome
4, // 1: enkly.agent.v1.TurnEvent.tool_call:type_name -> enkly.agent.v1.ToolCall
7, // 2: enkly.agent.v1.TurnEvent.done:type_name -> enkly.agent.v1.TurnDone
5, // 3: enkly.agent.v1.TurnEvent.thinking_round:type_name -> enkly.agent.v1.ThinkingRound
4, // 4: enkly.agent.v1.TurnDone.tool_calls:type_name -> enkly.agent.v1.ToolCall
1, // 5: enkly.agent.v1.AgentService.StartTurn:input_type -> enkly.agent.v1.StartTurnRequest
3, // 6: enkly.agent.v1.AgentService.StreamTurn:input_type -> enkly.agent.v1.StreamTurnRequest
8, // 7: enkly.agent.v1.AgentService.StopTurn:input_type -> enkly.agent.v1.StopTurnRequest
2, // 8: enkly.agent.v1.AgentService.StartTurn:output_type -> enkly.agent.v1.StartTurnResponse
6, // 9: enkly.agent.v1.AgentService.StreamTurn:output_type -> enkly.agent.v1.TurnEvent
9, // 10: enkly.agent.v1.AgentService.StopTurn:output_type -> enkly.agent.v1.StopTurnResponse
8, // [8:11] is the sub-list for method output_type
5, // [5:8] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
}
func init() { file_agent_v1_agent_proto_init() }
func file_agent_v1_agent_proto_init() {
if File_agent_v1_agent_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_agent_v1_agent_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*StartTurnRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_agent_v1_agent_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*StartTurnResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_agent_v1_agent_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*StreamTurnRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_agent_v1_agent_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*ToolCall); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_agent_v1_agent_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*ThinkingRound); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_agent_v1_agent_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*TurnEvent); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_agent_v1_agent_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*TurnDone); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_agent_v1_agent_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*StopTurnRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_agent_v1_agent_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*StopTurnResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
file_agent_v1_agent_proto_msgTypes[5].OneofWrappers = []any{
(*TurnEvent_ThinkingDelta)(nil),
(*TurnEvent_TokenDelta)(nil),
(*TurnEvent_ToolCall)(nil),
(*TurnEvent_Done)(nil),
(*TurnEvent_ThinkingRound)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_agent_v1_agent_proto_rawDesc,
NumEnums: 1,
NumMessages: 9,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_agent_v1_agent_proto_goTypes,
DependencyIndexes: file_agent_v1_agent_proto_depIdxs,
EnumInfos: file_agent_v1_agent_proto_enumTypes,
MessageInfos: file_agent_v1_agent_proto_msgTypes,
}.Build()
File_agent_v1_agent_proto = out.File
file_agent_v1_agent_proto_rawDesc = nil
file_agent_v1_agent_proto_goTypes = nil
file_agent_v1_agent_proto_depIdxs = nil
}

View File

@@ -0,0 +1,240 @@
// Package agent is the Agent Orchestration bounded context: the agentic
// run loop (prompt -> vLLM -> tool calls -> final reply), the chat-run
// quota/concurrency guard, and the in-flight/just-finished run registry
// that used to live in internal/handlers/chat.go's StreamManager.
//
// This is a saga coordinator, not a data owner: it holds no SQL table of
// its own. It calls billing (usage quota), conversation (persisting
// turns) and knowledge (the search_codebase tool) over gRPC, and
// workspace (every filesystem/bash tool) over gRPC, exactly replacing
// internal/llm/agent.go + internal/llm/tools.go's direct, in-process
// calls into internal/db and the local filesystem.
//
// Rendering (Markdown, syntax highlighting, deliverable cards) stays out
// of this service on purpose — see internal/render's doc comment in the
// gateway service — so this API deals in plain text and structured tool
// results, never HTML.
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v3.21.12
// source: agent/v1/agent.proto
package agentv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
AgentService_StartTurn_FullMethodName = "/enkly.agent.v1.AgentService/StartTurn"
AgentService_StreamTurn_FullMethodName = "/enkly.agent.v1.AgentService/StreamTurn"
AgentService_StopTurn_FullMethodName = "/enkly.agent.v1.AgentService/StopTurn"
)
// AgentServiceClient is the client API for AgentService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type AgentServiceClient interface {
// StartTurn replaces the synchronous prefix of Handlers.Chat: the
// per-user throttle (ChatLimiter), the billing quota check, loading
// the conversation, persisting the user's prompt, auto-titling, and
// registering a new run (StreamManager.create) before handing off to
// a background goroutine. Returns immediately with a stream_id; the
// reply itself arrives via StreamTurn.
StartTurn(ctx context.Context, in *StartTurnRequest, opts ...grpc.CallOption) (*StartTurnResponse, error)
// StreamTurn replaces Handlers.StreamChat: a server-streaming view of
// one run's progress. resume_from_tool_index mirrors the monolith's
// SSE Last-Event-ID handling, so a reconnecting client doesn't see
// tool cards it already has.
StreamTurn(ctx context.Context, in *StreamTurnRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TurnEvent], error)
// StopTurn replaces Handlers.StopStream.
StopTurn(ctx context.Context, in *StopTurnRequest, opts ...grpc.CallOption) (*StopTurnResponse, error)
}
type agentServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAgentServiceClient(cc grpc.ClientConnInterface) AgentServiceClient {
return &agentServiceClient{cc}
}
func (c *agentServiceClient) StartTurn(ctx context.Context, in *StartTurnRequest, opts ...grpc.CallOption) (*StartTurnResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StartTurnResponse)
err := c.cc.Invoke(ctx, AgentService_StartTurn_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *agentServiceClient) StreamTurn(ctx context.Context, in *StreamTurnRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TurnEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[0], AgentService_StreamTurn_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[StreamTurnRequest, TurnEvent]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_StreamTurnClient = grpc.ServerStreamingClient[TurnEvent]
func (c *agentServiceClient) StopTurn(ctx context.Context, in *StopTurnRequest, opts ...grpc.CallOption) (*StopTurnResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StopTurnResponse)
err := c.cc.Invoke(ctx, AgentService_StopTurn_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AgentServiceServer is the server API for AgentService service.
// All implementations must embed UnimplementedAgentServiceServer
// for forward compatibility.
type AgentServiceServer interface {
// StartTurn replaces the synchronous prefix of Handlers.Chat: the
// per-user throttle (ChatLimiter), the billing quota check, loading
// the conversation, persisting the user's prompt, auto-titling, and
// registering a new run (StreamManager.create) before handing off to
// a background goroutine. Returns immediately with a stream_id; the
// reply itself arrives via StreamTurn.
StartTurn(context.Context, *StartTurnRequest) (*StartTurnResponse, error)
// StreamTurn replaces Handlers.StreamChat: a server-streaming view of
// one run's progress. resume_from_tool_index mirrors the monolith's
// SSE Last-Event-ID handling, so a reconnecting client doesn't see
// tool cards it already has.
StreamTurn(*StreamTurnRequest, grpc.ServerStreamingServer[TurnEvent]) error
// StopTurn replaces Handlers.StopStream.
StopTurn(context.Context, *StopTurnRequest) (*StopTurnResponse, error)
mustEmbedUnimplementedAgentServiceServer()
}
// UnimplementedAgentServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAgentServiceServer struct{}
func (UnimplementedAgentServiceServer) StartTurn(context.Context, *StartTurnRequest) (*StartTurnResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StartTurn not implemented")
}
func (UnimplementedAgentServiceServer) StreamTurn(*StreamTurnRequest, grpc.ServerStreamingServer[TurnEvent]) error {
return status.Errorf(codes.Unimplemented, "method StreamTurn not implemented")
}
func (UnimplementedAgentServiceServer) StopTurn(context.Context, *StopTurnRequest) (*StopTurnResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StopTurn not implemented")
}
func (UnimplementedAgentServiceServer) mustEmbedUnimplementedAgentServiceServer() {}
func (UnimplementedAgentServiceServer) testEmbeddedByValue() {}
// UnsafeAgentServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AgentServiceServer will
// result in compilation errors.
type UnsafeAgentServiceServer interface {
mustEmbedUnimplementedAgentServiceServer()
}
func RegisterAgentServiceServer(s grpc.ServiceRegistrar, srv AgentServiceServer) {
// If the following call pancis, it indicates UnimplementedAgentServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AgentService_ServiceDesc, srv)
}
func _AgentService_StartTurn_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartTurnRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServiceServer).StartTurn(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AgentService_StartTurn_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServiceServer).StartTurn(ctx, req.(*StartTurnRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AgentService_StreamTurn_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(StreamTurnRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(AgentServiceServer).StreamTurn(m, &grpc.GenericServerStream[StreamTurnRequest, TurnEvent]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_StreamTurnServer = grpc.ServerStreamingServer[TurnEvent]
func _AgentService_StopTurn_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StopTurnRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServiceServer).StopTurn(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AgentService_StopTurn_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServiceServer).StopTurn(ctx, req.(*StopTurnRequest))
}
return interceptor(ctx, in, info, handler)
}
// AgentService_ServiceDesc is the grpc.ServiceDesc for AgentService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AgentService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "enkly.agent.v1.AgentService",
HandlerType: (*AgentServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "StartTurn",
Handler: _AgentService_StartTurn_Handler,
},
{
MethodName: "StopTurn",
Handler: _AgentService_StopTurn_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "StreamTurn",
Handler: _AgentService_StreamTurn_Handler,
ServerStreams: true,
},
},
Metadata: "agent/v1/agent.proto",
}

1455
gen/billing/v1/billing.pb.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,445 @@
// Package billing is the Billing & Subscription bounded context: plans,
// Stripe checkout/portal, webhook ingestion, and the per-plan daily
// message quota. System of record for `subscriptions`, `stripe_events`
// and `usage_daily` (see services/billing/migrations/0001_init.sql).
//
// Mirrors internal/handlers/billing.go + internal/billing/*.go +
// internal/db/subscriptions.go.
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v3.21.12
// source: billing/v1/billing.proto
package billingv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
BillingService_GetOrCreateSubscription_FullMethodName = "/enkly.billing.v1.BillingService/GetOrCreateSubscription"
BillingService_GetPlanCatalog_FullMethodName = "/enkly.billing.v1.BillingService/GetPlanCatalog"
BillingService_CreateCheckoutSession_FullMethodName = "/enkly.billing.v1.BillingService/CreateCheckoutSession"
BillingService_SyncCheckoutSession_FullMethodName = "/enkly.billing.v1.BillingService/SyncCheckoutSession"
BillingService_CreateBillingPortalSession_FullMethodName = "/enkly.billing.v1.BillingService/CreateBillingPortalSession"
BillingService_IngestStripeWebhook_FullMethodName = "/enkly.billing.v1.BillingService/IngestStripeWebhook"
BillingService_CheckAndIncrementUsage_FullMethodName = "/enkly.billing.v1.BillingService/CheckAndIncrementUsage"
BillingService_GetDailyUsage_FullMethodName = "/enkly.billing.v1.BillingService/GetDailyUsage"
)
// BillingServiceClient is the client API for BillingService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type BillingServiceClient interface {
// GetOrCreateSubscription replaces db.GetOrCreateSubscription: lazily
// inserts the default free-plan row the first time a user's
// subscription is looked up.
GetOrCreateSubscription(ctx context.Context, in *GetOrCreateSubscriptionRequest, opts ...grpc.CallOption) (*Subscription, error)
// GetPlanCatalog replaces billing.Catalog + billing.PlanPriceIDs for
// rendering /subscription — returns every plan and whether its Stripe
// price is currently configured (so an unconfigured plan renders as
// "Unavailable", same as today).
GetPlanCatalog(ctx context.Context, in *GetPlanCatalogRequest, opts ...grpc.CallOption) (*GetPlanCatalogResponse, error)
// CreateCheckoutSession replaces Handlers.CreateCheckout.
CreateCheckoutSession(ctx context.Context, in *CreateCheckoutSessionRequest, opts ...grpc.CallOption) (*CreateCheckoutSessionResponse, error)
// SyncCheckoutSession replaces Handlers.syncCheckoutSession, the
// fallback path SubscriptionSuccess uses when the webhook hasn't
// landed yet.
SyncCheckoutSession(ctx context.Context, in *SyncCheckoutSessionRequest, opts ...grpc.CallOption) (*Subscription, error)
// CreateBillingPortalSession replaces Handlers.BillingPortal.
CreateBillingPortalSession(ctx context.Context, in *CreateBillingPortalSessionRequest, opts ...grpc.CallOption) (*CreateBillingPortalSessionResponse, error)
// IngestStripeWebhook replaces Handlers.StripeWebhook /
// applyStripeEvent. The gateway still terminates the public
// POST /webhooks/stripe route (so the URL Stripe is configured with
// never changes) but forwards the raw body and signature header here
// untouched — signature verification needs STRIPE_WEBHOOK_SECRET,
// which only billing holds.
IngestStripeWebhook(ctx context.Context, in *IngestStripeWebhookRequest, opts ...grpc.CallOption) (*IngestStripeWebhookResponse, error)
// CheckAndIncrementUsage replaces the daily-cap check + db.
// IncrementDailyUsage call in Handlers.Chat. Called by the agent
// service once per chat turn, after the message is accepted and
// before the agent run starts (same ordering as the monolith).
CheckAndIncrementUsage(ctx context.Context, in *CheckAndIncrementUsageRequest, opts ...grpc.CallOption) (*CheckAndIncrementUsageResponse, error)
// GetDailyUsage replaces db.DailyUsage — a read-only lookup (no
// increment) used by the gateway's /subscription page to show
// today's count without miscounting it as a sent message.
GetDailyUsage(ctx context.Context, in *GetDailyUsageRequest, opts ...grpc.CallOption) (*GetDailyUsageResponse, error)
}
type billingServiceClient struct {
cc grpc.ClientConnInterface
}
func NewBillingServiceClient(cc grpc.ClientConnInterface) BillingServiceClient {
return &billingServiceClient{cc}
}
func (c *billingServiceClient) GetOrCreateSubscription(ctx context.Context, in *GetOrCreateSubscriptionRequest, opts ...grpc.CallOption) (*Subscription, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Subscription)
err := c.cc.Invoke(ctx, BillingService_GetOrCreateSubscription_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *billingServiceClient) GetPlanCatalog(ctx context.Context, in *GetPlanCatalogRequest, opts ...grpc.CallOption) (*GetPlanCatalogResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetPlanCatalogResponse)
err := c.cc.Invoke(ctx, BillingService_GetPlanCatalog_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *billingServiceClient) CreateCheckoutSession(ctx context.Context, in *CreateCheckoutSessionRequest, opts ...grpc.CallOption) (*CreateCheckoutSessionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateCheckoutSessionResponse)
err := c.cc.Invoke(ctx, BillingService_CreateCheckoutSession_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *billingServiceClient) SyncCheckoutSession(ctx context.Context, in *SyncCheckoutSessionRequest, opts ...grpc.CallOption) (*Subscription, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Subscription)
err := c.cc.Invoke(ctx, BillingService_SyncCheckoutSession_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *billingServiceClient) CreateBillingPortalSession(ctx context.Context, in *CreateBillingPortalSessionRequest, opts ...grpc.CallOption) (*CreateBillingPortalSessionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateBillingPortalSessionResponse)
err := c.cc.Invoke(ctx, BillingService_CreateBillingPortalSession_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *billingServiceClient) IngestStripeWebhook(ctx context.Context, in *IngestStripeWebhookRequest, opts ...grpc.CallOption) (*IngestStripeWebhookResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(IngestStripeWebhookResponse)
err := c.cc.Invoke(ctx, BillingService_IngestStripeWebhook_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *billingServiceClient) CheckAndIncrementUsage(ctx context.Context, in *CheckAndIncrementUsageRequest, opts ...grpc.CallOption) (*CheckAndIncrementUsageResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CheckAndIncrementUsageResponse)
err := c.cc.Invoke(ctx, BillingService_CheckAndIncrementUsage_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *billingServiceClient) GetDailyUsage(ctx context.Context, in *GetDailyUsageRequest, opts ...grpc.CallOption) (*GetDailyUsageResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetDailyUsageResponse)
err := c.cc.Invoke(ctx, BillingService_GetDailyUsage_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// BillingServiceServer is the server API for BillingService service.
// All implementations must embed UnimplementedBillingServiceServer
// for forward compatibility.
type BillingServiceServer interface {
// GetOrCreateSubscription replaces db.GetOrCreateSubscription: lazily
// inserts the default free-plan row the first time a user's
// subscription is looked up.
GetOrCreateSubscription(context.Context, *GetOrCreateSubscriptionRequest) (*Subscription, error)
// GetPlanCatalog replaces billing.Catalog + billing.PlanPriceIDs for
// rendering /subscription — returns every plan and whether its Stripe
// price is currently configured (so an unconfigured plan renders as
// "Unavailable", same as today).
GetPlanCatalog(context.Context, *GetPlanCatalogRequest) (*GetPlanCatalogResponse, error)
// CreateCheckoutSession replaces Handlers.CreateCheckout.
CreateCheckoutSession(context.Context, *CreateCheckoutSessionRequest) (*CreateCheckoutSessionResponse, error)
// SyncCheckoutSession replaces Handlers.syncCheckoutSession, the
// fallback path SubscriptionSuccess uses when the webhook hasn't
// landed yet.
SyncCheckoutSession(context.Context, *SyncCheckoutSessionRequest) (*Subscription, error)
// CreateBillingPortalSession replaces Handlers.BillingPortal.
CreateBillingPortalSession(context.Context, *CreateBillingPortalSessionRequest) (*CreateBillingPortalSessionResponse, error)
// IngestStripeWebhook replaces Handlers.StripeWebhook /
// applyStripeEvent. The gateway still terminates the public
// POST /webhooks/stripe route (so the URL Stripe is configured with
// never changes) but forwards the raw body and signature header here
// untouched — signature verification needs STRIPE_WEBHOOK_SECRET,
// which only billing holds.
IngestStripeWebhook(context.Context, *IngestStripeWebhookRequest) (*IngestStripeWebhookResponse, error)
// CheckAndIncrementUsage replaces the daily-cap check + db.
// IncrementDailyUsage call in Handlers.Chat. Called by the agent
// service once per chat turn, after the message is accepted and
// before the agent run starts (same ordering as the monolith).
CheckAndIncrementUsage(context.Context, *CheckAndIncrementUsageRequest) (*CheckAndIncrementUsageResponse, error)
// GetDailyUsage replaces db.DailyUsage — a read-only lookup (no
// increment) used by the gateway's /subscription page to show
// today's count without miscounting it as a sent message.
GetDailyUsage(context.Context, *GetDailyUsageRequest) (*GetDailyUsageResponse, error)
mustEmbedUnimplementedBillingServiceServer()
}
// UnimplementedBillingServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedBillingServiceServer struct{}
func (UnimplementedBillingServiceServer) GetOrCreateSubscription(context.Context, *GetOrCreateSubscriptionRequest) (*Subscription, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetOrCreateSubscription not implemented")
}
func (UnimplementedBillingServiceServer) GetPlanCatalog(context.Context, *GetPlanCatalogRequest) (*GetPlanCatalogResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetPlanCatalog not implemented")
}
func (UnimplementedBillingServiceServer) CreateCheckoutSession(context.Context, *CreateCheckoutSessionRequest) (*CreateCheckoutSessionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateCheckoutSession not implemented")
}
func (UnimplementedBillingServiceServer) SyncCheckoutSession(context.Context, *SyncCheckoutSessionRequest) (*Subscription, error) {
return nil, status.Errorf(codes.Unimplemented, "method SyncCheckoutSession not implemented")
}
func (UnimplementedBillingServiceServer) CreateBillingPortalSession(context.Context, *CreateBillingPortalSessionRequest) (*CreateBillingPortalSessionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateBillingPortalSession not implemented")
}
func (UnimplementedBillingServiceServer) IngestStripeWebhook(context.Context, *IngestStripeWebhookRequest) (*IngestStripeWebhookResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method IngestStripeWebhook not implemented")
}
func (UnimplementedBillingServiceServer) CheckAndIncrementUsage(context.Context, *CheckAndIncrementUsageRequest) (*CheckAndIncrementUsageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CheckAndIncrementUsage not implemented")
}
func (UnimplementedBillingServiceServer) GetDailyUsage(context.Context, *GetDailyUsageRequest) (*GetDailyUsageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetDailyUsage not implemented")
}
func (UnimplementedBillingServiceServer) mustEmbedUnimplementedBillingServiceServer() {}
func (UnimplementedBillingServiceServer) testEmbeddedByValue() {}
// UnsafeBillingServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to BillingServiceServer will
// result in compilation errors.
type UnsafeBillingServiceServer interface {
mustEmbedUnimplementedBillingServiceServer()
}
func RegisterBillingServiceServer(s grpc.ServiceRegistrar, srv BillingServiceServer) {
// If the following call pancis, it indicates UnimplementedBillingServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&BillingService_ServiceDesc, srv)
}
func _BillingService_GetOrCreateSubscription_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetOrCreateSubscriptionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BillingServiceServer).GetOrCreateSubscription(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BillingService_GetOrCreateSubscription_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BillingServiceServer).GetOrCreateSubscription(ctx, req.(*GetOrCreateSubscriptionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BillingService_GetPlanCatalog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetPlanCatalogRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BillingServiceServer).GetPlanCatalog(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BillingService_GetPlanCatalog_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BillingServiceServer).GetPlanCatalog(ctx, req.(*GetPlanCatalogRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BillingService_CreateCheckoutSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateCheckoutSessionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BillingServiceServer).CreateCheckoutSession(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BillingService_CreateCheckoutSession_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BillingServiceServer).CreateCheckoutSession(ctx, req.(*CreateCheckoutSessionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BillingService_SyncCheckoutSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SyncCheckoutSessionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BillingServiceServer).SyncCheckoutSession(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BillingService_SyncCheckoutSession_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BillingServiceServer).SyncCheckoutSession(ctx, req.(*SyncCheckoutSessionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BillingService_CreateBillingPortalSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateBillingPortalSessionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BillingServiceServer).CreateBillingPortalSession(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BillingService_CreateBillingPortalSession_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BillingServiceServer).CreateBillingPortalSession(ctx, req.(*CreateBillingPortalSessionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BillingService_IngestStripeWebhook_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(IngestStripeWebhookRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BillingServiceServer).IngestStripeWebhook(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BillingService_IngestStripeWebhook_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BillingServiceServer).IngestStripeWebhook(ctx, req.(*IngestStripeWebhookRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BillingService_CheckAndIncrementUsage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CheckAndIncrementUsageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BillingServiceServer).CheckAndIncrementUsage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BillingService_CheckAndIncrementUsage_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BillingServiceServer).CheckAndIncrementUsage(ctx, req.(*CheckAndIncrementUsageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BillingService_GetDailyUsage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDailyUsageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BillingServiceServer).GetDailyUsage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BillingService_GetDailyUsage_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BillingServiceServer).GetDailyUsage(ctx, req.(*GetDailyUsageRequest))
}
return interceptor(ctx, in, info, handler)
}
// BillingService_ServiceDesc is the grpc.ServiceDesc for BillingService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var BillingService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "enkly.billing.v1.BillingService",
HandlerType: (*BillingServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetOrCreateSubscription",
Handler: _BillingService_GetOrCreateSubscription_Handler,
},
{
MethodName: "GetPlanCatalog",
Handler: _BillingService_GetPlanCatalog_Handler,
},
{
MethodName: "CreateCheckoutSession",
Handler: _BillingService_CreateCheckoutSession_Handler,
},
{
MethodName: "SyncCheckoutSession",
Handler: _BillingService_SyncCheckoutSession_Handler,
},
{
MethodName: "CreateBillingPortalSession",
Handler: _BillingService_CreateBillingPortalSession_Handler,
},
{
MethodName: "IngestStripeWebhook",
Handler: _BillingService_IngestStripeWebhook_Handler,
},
{
MethodName: "CheckAndIncrementUsage",
Handler: _BillingService_CheckAndIncrementUsage_Handler,
},
{
MethodName: "GetDailyUsage",
Handler: _BillingService_GetDailyUsage_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "billing/v1/billing.proto",
}

301
gen/common/v1/common.pb.go Normal file
View File

@@ -0,0 +1,301 @@
// Package common holds message types shared across every enkly bounded
// context's gRPC API, so each context's own .proto only needs to import
// this one file instead of depending on each other directly.
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.34.2
// protoc v3.21.12
// source: common/v1/common.proto
package commonv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// Money is a decimal amount in the smallest unit of currency (cents),
// matching how internal/billing.Info prices plans today (EUR integer
// amounts) without pulling in a floating point rounding bug.
type Money struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
MinorUnits int64 `protobuf:"varint,1,opt,name=minor_units,json=minorUnits,proto3" json:"minor_units,omitempty"` // e.g. 500 = 5.00
CurrencyCode string `protobuf:"bytes,2,opt,name=currency_code,json=currencyCode,proto3" json:"currency_code,omitempty"` // ISO 4217, e.g. "EUR"
}
func (x *Money) Reset() {
*x = Money{}
if protoimpl.UnsafeEnabled {
mi := &file_common_v1_common_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *Money) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Money) ProtoMessage() {}
func (x *Money) ProtoReflect() protoreflect.Message {
mi := &file_common_v1_common_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Money.ProtoReflect.Descriptor instead.
func (*Money) Descriptor() ([]byte, []int) {
return file_common_v1_common_proto_rawDescGZIP(), []int{0}
}
func (x *Money) GetMinorUnits() int64 {
if x != nil {
return x.MinorUnits
}
return 0
}
func (x *Money) GetCurrencyCode() string {
if x != nil {
return x.CurrencyCode
}
return ""
}
// PageRequest / PageResponse: not used by today's list endpoints (the
// monolith never paginates conversations or messages), reserved so a
// service can add pagination later without a breaking proto change.
type PageRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
}
func (x *PageRequest) Reset() {
*x = PageRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_common_v1_common_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *PageRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PageRequest) ProtoMessage() {}
func (x *PageRequest) ProtoReflect() protoreflect.Message {
mi := &file_common_v1_common_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PageRequest.ProtoReflect.Descriptor instead.
func (*PageRequest) Descriptor() ([]byte, []int) {
return file_common_v1_common_proto_rawDescGZIP(), []int{1}
}
func (x *PageRequest) GetPageSize() int32 {
if x != nil {
return x.PageSize
}
return 0
}
func (x *PageRequest) GetPageToken() string {
if x != nil {
return x.PageToken
}
return ""
}
type PageResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
NextPageToken string `protobuf:"bytes,1,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
}
func (x *PageResponse) Reset() {
*x = PageResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_common_v1_common_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *PageResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PageResponse) ProtoMessage() {}
func (x *PageResponse) ProtoReflect() protoreflect.Message {
mi := &file_common_v1_common_proto_msgTypes[2]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PageResponse.ProtoReflect.Descriptor instead.
func (*PageResponse) Descriptor() ([]byte, []int) {
return file_common_v1_common_proto_rawDescGZIP(), []int{2}
}
func (x *PageResponse) GetNextPageToken() string {
if x != nil {
return x.NextPageToken
}
return ""
}
var File_common_v1_common_proto protoreflect.FileDescriptor
var file_common_v1_common_proto_rawDesc = []byte{
0x0a, 0x16, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x6d,
0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0f, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e,
0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x22, 0x4d, 0x0a, 0x05, 0x4d, 0x6f, 0x6e,
0x65, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x6d, 0x69, 0x6e, 0x6f, 0x72, 0x5f, 0x75, 0x6e, 0x69, 0x74,
0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x6d, 0x69, 0x6e, 0x6f, 0x72, 0x55, 0x6e,
0x69, 0x74, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x5f,
0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x75, 0x72, 0x72,
0x65, 0x6e, 0x63, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x22, 0x49, 0x0a, 0x0b, 0x50, 0x61, 0x67, 0x65,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f,
0x73, 0x69, 0x7a, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65,
0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b,
0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f,
0x6b, 0x65, 0x6e, 0x22, 0x36, 0x0a, 0x0c, 0x50, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65,
0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65,
0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x42, 0x1e, 0x5a, 0x1c, 0x65,
0x6e, 0x6b, 0x6c, 0x79, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f,
0x76, 0x31, 0x3b, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x33,
}
var (
file_common_v1_common_proto_rawDescOnce sync.Once
file_common_v1_common_proto_rawDescData = file_common_v1_common_proto_rawDesc
)
func file_common_v1_common_proto_rawDescGZIP() []byte {
file_common_v1_common_proto_rawDescOnce.Do(func() {
file_common_v1_common_proto_rawDescData = protoimpl.X.CompressGZIP(file_common_v1_common_proto_rawDescData)
})
return file_common_v1_common_proto_rawDescData
}
var file_common_v1_common_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_common_v1_common_proto_goTypes = []any{
(*Money)(nil), // 0: enkly.common.v1.Money
(*PageRequest)(nil), // 1: enkly.common.v1.PageRequest
(*PageResponse)(nil), // 2: enkly.common.v1.PageResponse
}
var file_common_v1_common_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type
0, // [0:0] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_common_v1_common_proto_init() }
func file_common_v1_common_proto_init() {
if File_common_v1_common_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_common_v1_common_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*Money); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_common_v1_common_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*PageRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_common_v1_common_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*PageResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_common_v1_common_proto_rawDesc,
NumEnums: 0,
NumMessages: 3,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_common_v1_common_proto_goTypes,
DependencyIndexes: file_common_v1_common_proto_depIdxs,
MessageInfos: file_common_v1_common_proto_msgTypes,
}.Build()
File_common_v1_common_proto = out.File
file_common_v1_common_proto_rawDesc = nil
file_common_v1_common_proto_goTypes = nil
file_common_v1_common_proto_depIdxs = nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,516 @@
// Package conversation is the Conversation bounded context: chat threads
// and their messages/tool-call audit log. System of record for
// `conversations`, `messages` and `tool_calls` (see
// services/conversation/migrations/0001_init.sql).
//
// Mirrors internal/db/conversations.go, called both by the gateway
// (listing/renaming/deleting threads, loading history to render) and by
// the agent service (appending messages as a run proceeds).
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v3.21.12
// source: conversation/v1/conversation.proto
package conversationv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
ConversationService_CreateConversation_FullMethodName = "/enkly.conversation.v1.ConversationService/CreateConversation"
ConversationService_GetConversation_FullMethodName = "/enkly.conversation.v1.ConversationService/GetConversation"
ConversationService_ListConversations_FullMethodName = "/enkly.conversation.v1.ConversationService/ListConversations"
ConversationService_MostRecentConversation_FullMethodName = "/enkly.conversation.v1.ConversationService/MostRecentConversation"
ConversationService_RenameConversation_FullMethodName = "/enkly.conversation.v1.ConversationService/RenameConversation"
ConversationService_AutoTitleConversation_FullMethodName = "/enkly.conversation.v1.ConversationService/AutoTitleConversation"
ConversationService_DeleteConversation_FullMethodName = "/enkly.conversation.v1.ConversationService/DeleteConversation"
ConversationService_CountMessages_FullMethodName = "/enkly.conversation.v1.ConversationService/CountMessages"
ConversationService_AddMessage_FullMethodName = "/enkly.conversation.v1.ConversationService/AddMessage"
ConversationService_ListMessages_FullMethodName = "/enkly.conversation.v1.ConversationService/ListMessages"
ConversationService_AddToolCall_FullMethodName = "/enkly.conversation.v1.ConversationService/AddToolCall"
)
// ConversationServiceClient is the client API for ConversationService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type ConversationServiceClient interface {
CreateConversation(ctx context.Context, in *CreateConversationRequest, opts ...grpc.CallOption) (*Conversation, error)
GetConversation(ctx context.Context, in *GetConversationRequest, opts ...grpc.CallOption) (*Conversation, error)
ListConversations(ctx context.Context, in *ListConversationsRequest, opts ...grpc.CallOption) (*ListConversationsResponse, error)
MostRecentConversation(ctx context.Context, in *MostRecentConversationRequest, opts ...grpc.CallOption) (*Conversation, error)
RenameConversation(ctx context.Context, in *RenameConversationRequest, opts ...grpc.CallOption) (*Conversation, error)
// AutoTitleConversation replaces db.AutoTitleConversation: sets the
// title from the first prompt only if the conversation still has the
// default title (returns retitled=true when it actually changed it).
AutoTitleConversation(ctx context.Context, in *AutoTitleConversationRequest, opts ...grpc.CallOption) (*AutoTitleConversationResponse, error)
DeleteConversation(ctx context.Context, in *DeleteConversationRequest, opts ...grpc.CallOption) (*DeleteConversationResponse, error)
CountMessages(ctx context.Context, in *CountMessagesRequest, opts ...grpc.CallOption) (*CountMessagesResponse, error)
AddMessage(ctx context.Context, in *AddMessageRequest, opts ...grpc.CallOption) (*Message, error)
ListMessages(ctx context.Context, in *ListMessagesRequest, opts ...grpc.CallOption) (*ListMessagesResponse, error)
AddToolCall(ctx context.Context, in *AddToolCallRequest, opts ...grpc.CallOption) (*AddToolCallResponse, error)
}
type conversationServiceClient struct {
cc grpc.ClientConnInterface
}
func NewConversationServiceClient(cc grpc.ClientConnInterface) ConversationServiceClient {
return &conversationServiceClient{cc}
}
func (c *conversationServiceClient) CreateConversation(ctx context.Context, in *CreateConversationRequest, opts ...grpc.CallOption) (*Conversation, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Conversation)
err := c.cc.Invoke(ctx, ConversationService_CreateConversation_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *conversationServiceClient) GetConversation(ctx context.Context, in *GetConversationRequest, opts ...grpc.CallOption) (*Conversation, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Conversation)
err := c.cc.Invoke(ctx, ConversationService_GetConversation_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *conversationServiceClient) ListConversations(ctx context.Context, in *ListConversationsRequest, opts ...grpc.CallOption) (*ListConversationsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListConversationsResponse)
err := c.cc.Invoke(ctx, ConversationService_ListConversations_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *conversationServiceClient) MostRecentConversation(ctx context.Context, in *MostRecentConversationRequest, opts ...grpc.CallOption) (*Conversation, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Conversation)
err := c.cc.Invoke(ctx, ConversationService_MostRecentConversation_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *conversationServiceClient) RenameConversation(ctx context.Context, in *RenameConversationRequest, opts ...grpc.CallOption) (*Conversation, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Conversation)
err := c.cc.Invoke(ctx, ConversationService_RenameConversation_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *conversationServiceClient) AutoTitleConversation(ctx context.Context, in *AutoTitleConversationRequest, opts ...grpc.CallOption) (*AutoTitleConversationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AutoTitleConversationResponse)
err := c.cc.Invoke(ctx, ConversationService_AutoTitleConversation_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *conversationServiceClient) DeleteConversation(ctx context.Context, in *DeleteConversationRequest, opts ...grpc.CallOption) (*DeleteConversationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteConversationResponse)
err := c.cc.Invoke(ctx, ConversationService_DeleteConversation_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *conversationServiceClient) CountMessages(ctx context.Context, in *CountMessagesRequest, opts ...grpc.CallOption) (*CountMessagesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CountMessagesResponse)
err := c.cc.Invoke(ctx, ConversationService_CountMessages_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *conversationServiceClient) AddMessage(ctx context.Context, in *AddMessageRequest, opts ...grpc.CallOption) (*Message, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Message)
err := c.cc.Invoke(ctx, ConversationService_AddMessage_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *conversationServiceClient) ListMessages(ctx context.Context, in *ListMessagesRequest, opts ...grpc.CallOption) (*ListMessagesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListMessagesResponse)
err := c.cc.Invoke(ctx, ConversationService_ListMessages_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *conversationServiceClient) AddToolCall(ctx context.Context, in *AddToolCallRequest, opts ...grpc.CallOption) (*AddToolCallResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AddToolCallResponse)
err := c.cc.Invoke(ctx, ConversationService_AddToolCall_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ConversationServiceServer is the server API for ConversationService service.
// All implementations must embed UnimplementedConversationServiceServer
// for forward compatibility.
type ConversationServiceServer interface {
CreateConversation(context.Context, *CreateConversationRequest) (*Conversation, error)
GetConversation(context.Context, *GetConversationRequest) (*Conversation, error)
ListConversations(context.Context, *ListConversationsRequest) (*ListConversationsResponse, error)
MostRecentConversation(context.Context, *MostRecentConversationRequest) (*Conversation, error)
RenameConversation(context.Context, *RenameConversationRequest) (*Conversation, error)
// AutoTitleConversation replaces db.AutoTitleConversation: sets the
// title from the first prompt only if the conversation still has the
// default title (returns retitled=true when it actually changed it).
AutoTitleConversation(context.Context, *AutoTitleConversationRequest) (*AutoTitleConversationResponse, error)
DeleteConversation(context.Context, *DeleteConversationRequest) (*DeleteConversationResponse, error)
CountMessages(context.Context, *CountMessagesRequest) (*CountMessagesResponse, error)
AddMessage(context.Context, *AddMessageRequest) (*Message, error)
ListMessages(context.Context, *ListMessagesRequest) (*ListMessagesResponse, error)
AddToolCall(context.Context, *AddToolCallRequest) (*AddToolCallResponse, error)
mustEmbedUnimplementedConversationServiceServer()
}
// UnimplementedConversationServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedConversationServiceServer struct{}
func (UnimplementedConversationServiceServer) CreateConversation(context.Context, *CreateConversationRequest) (*Conversation, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateConversation not implemented")
}
func (UnimplementedConversationServiceServer) GetConversation(context.Context, *GetConversationRequest) (*Conversation, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetConversation not implemented")
}
func (UnimplementedConversationServiceServer) ListConversations(context.Context, *ListConversationsRequest) (*ListConversationsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListConversations not implemented")
}
func (UnimplementedConversationServiceServer) MostRecentConversation(context.Context, *MostRecentConversationRequest) (*Conversation, error) {
return nil, status.Errorf(codes.Unimplemented, "method MostRecentConversation not implemented")
}
func (UnimplementedConversationServiceServer) RenameConversation(context.Context, *RenameConversationRequest) (*Conversation, error) {
return nil, status.Errorf(codes.Unimplemented, "method RenameConversation not implemented")
}
func (UnimplementedConversationServiceServer) AutoTitleConversation(context.Context, *AutoTitleConversationRequest) (*AutoTitleConversationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AutoTitleConversation not implemented")
}
func (UnimplementedConversationServiceServer) DeleteConversation(context.Context, *DeleteConversationRequest) (*DeleteConversationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteConversation not implemented")
}
func (UnimplementedConversationServiceServer) CountMessages(context.Context, *CountMessagesRequest) (*CountMessagesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CountMessages not implemented")
}
func (UnimplementedConversationServiceServer) AddMessage(context.Context, *AddMessageRequest) (*Message, error) {
return nil, status.Errorf(codes.Unimplemented, "method AddMessage not implemented")
}
func (UnimplementedConversationServiceServer) ListMessages(context.Context, *ListMessagesRequest) (*ListMessagesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListMessages not implemented")
}
func (UnimplementedConversationServiceServer) AddToolCall(context.Context, *AddToolCallRequest) (*AddToolCallResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AddToolCall not implemented")
}
func (UnimplementedConversationServiceServer) mustEmbedUnimplementedConversationServiceServer() {}
func (UnimplementedConversationServiceServer) testEmbeddedByValue() {}
// UnsafeConversationServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ConversationServiceServer will
// result in compilation errors.
type UnsafeConversationServiceServer interface {
mustEmbedUnimplementedConversationServiceServer()
}
func RegisterConversationServiceServer(s grpc.ServiceRegistrar, srv ConversationServiceServer) {
// If the following call pancis, it indicates UnimplementedConversationServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&ConversationService_ServiceDesc, srv)
}
func _ConversationService_CreateConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateConversationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ConversationServiceServer).CreateConversation(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ConversationService_CreateConversation_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ConversationServiceServer).CreateConversation(ctx, req.(*CreateConversationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ConversationService_GetConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetConversationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ConversationServiceServer).GetConversation(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ConversationService_GetConversation_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ConversationServiceServer).GetConversation(ctx, req.(*GetConversationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ConversationService_ListConversations_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListConversationsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ConversationServiceServer).ListConversations(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ConversationService_ListConversations_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ConversationServiceServer).ListConversations(ctx, req.(*ListConversationsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ConversationService_MostRecentConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MostRecentConversationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ConversationServiceServer).MostRecentConversation(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ConversationService_MostRecentConversation_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ConversationServiceServer).MostRecentConversation(ctx, req.(*MostRecentConversationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ConversationService_RenameConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RenameConversationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ConversationServiceServer).RenameConversation(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ConversationService_RenameConversation_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ConversationServiceServer).RenameConversation(ctx, req.(*RenameConversationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ConversationService_AutoTitleConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AutoTitleConversationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ConversationServiceServer).AutoTitleConversation(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ConversationService_AutoTitleConversation_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ConversationServiceServer).AutoTitleConversation(ctx, req.(*AutoTitleConversationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ConversationService_DeleteConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteConversationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ConversationServiceServer).DeleteConversation(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ConversationService_DeleteConversation_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ConversationServiceServer).DeleteConversation(ctx, req.(*DeleteConversationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ConversationService_CountMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CountMessagesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ConversationServiceServer).CountMessages(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ConversationService_CountMessages_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ConversationServiceServer).CountMessages(ctx, req.(*CountMessagesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ConversationService_AddMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddMessageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ConversationServiceServer).AddMessage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ConversationService_AddMessage_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ConversationServiceServer).AddMessage(ctx, req.(*AddMessageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ConversationService_ListMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListMessagesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ConversationServiceServer).ListMessages(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ConversationService_ListMessages_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ConversationServiceServer).ListMessages(ctx, req.(*ListMessagesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ConversationService_AddToolCall_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddToolCallRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ConversationServiceServer).AddToolCall(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ConversationService_AddToolCall_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ConversationServiceServer).AddToolCall(ctx, req.(*AddToolCallRequest))
}
return interceptor(ctx, in, info, handler)
}
// ConversationService_ServiceDesc is the grpc.ServiceDesc for ConversationService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ConversationService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "enkly.conversation.v1.ConversationService",
HandlerType: (*ConversationServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "CreateConversation",
Handler: _ConversationService_CreateConversation_Handler,
},
{
MethodName: "GetConversation",
Handler: _ConversationService_GetConversation_Handler,
},
{
MethodName: "ListConversations",
Handler: _ConversationService_ListConversations_Handler,
},
{
MethodName: "MostRecentConversation",
Handler: _ConversationService_MostRecentConversation_Handler,
},
{
MethodName: "RenameConversation",
Handler: _ConversationService_RenameConversation_Handler,
},
{
MethodName: "AutoTitleConversation",
Handler: _ConversationService_AutoTitleConversation_Handler,
},
{
MethodName: "DeleteConversation",
Handler: _ConversationService_DeleteConversation_Handler,
},
{
MethodName: "CountMessages",
Handler: _ConversationService_CountMessages_Handler,
},
{
MethodName: "AddMessage",
Handler: _ConversationService_AddMessage_Handler,
},
{
MethodName: "ListMessages",
Handler: _ConversationService_ListMessages_Handler,
},
{
MethodName: "AddToolCall",
Handler: _ConversationService_AddToolCall_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "conversation/v1/conversation.proto",
}

1632
gen/iam/v1/iam.pb.go Normal file

File diff suppressed because it is too large Load Diff

523
gen/iam/v1/iam_grpc.pb.go Normal file
View File

@@ -0,0 +1,523 @@
// Package iam is the Identity & Access bounded context: accounts,
// sessions, email verification and password reset. It is the system of
// record for `users`, `sessions`, `email_verification_codes` and
// `password_reset_tokens` (see services/iam/migrations/0001_init.sql) —
// no other service may read or write those tables directly.
//
// Mirrors the monolith's internal/handlers/auth.go + internal/db/users.go
// + internal/auth/*.go one-for-one; see each rpc's comment for the
// handler function it replaces.
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v3.21.12
// source: iam/v1/iam.proto
package iamv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
IAMService_SignUp_FullMethodName = "/enkly.iam.v1.IAMService/SignUp"
IAMService_ResendVerification_FullMethodName = "/enkly.iam.v1.IAMService/ResendVerification"
IAMService_VerifyEmail_FullMethodName = "/enkly.iam.v1.IAMService/VerifyEmail"
IAMService_Login_FullMethodName = "/enkly.iam.v1.IAMService/Login"
IAMService_Logout_FullMethodName = "/enkly.iam.v1.IAMService/Logout"
IAMService_ValidateSession_FullMethodName = "/enkly.iam.v1.IAMService/ValidateSession"
IAMService_ForgotPassword_FullMethodName = "/enkly.iam.v1.IAMService/ForgotPassword"
IAMService_CheckPasswordResetToken_FullMethodName = "/enkly.iam.v1.IAMService/CheckPasswordResetToken"
IAMService_ResetPassword_FullMethodName = "/enkly.iam.v1.IAMService/ResetPassword"
IAMService_GetUser_FullMethodName = "/enkly.iam.v1.IAMService/GetUser"
)
// IAMServiceClient is the client API for IAMService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type IAMServiceClient interface {
// SignUp replaces Handlers.Signup. Validates and normalizes the email,
// hashes the password (PBKDF2, see internal/auth/password.go), creates
// the account (or silently reuses an existing unverified one — same
// anti-enumeration behavior as the monolith), issues a verification
// code and publishes an iam.verification_code.issued event for the
// notification service to deliver it. Never returns whether the email
// already existed.
SignUp(ctx context.Context, in *SignUpRequest, opts ...grpc.CallOption) (*SignUpResponse, error)
// ResendVerification replaces Handlers.ResendVerification.
ResendVerification(ctx context.Context, in *ResendVerificationRequest, opts ...grpc.CallOption) (*ResendVerificationResponse, error)
// VerifyEmail replaces Handlers.VerifyEmail.
VerifyEmail(ctx context.Context, in *VerifyEmailRequest, opts ...grpc.CallOption) (*VerifyEmailResponse, error)
// Login replaces Handlers.Login: verifies credentials (constant-time
// against a dummy hash for unknown emails) and, on success, mints a
// session and returns its raw token for the gateway to set as a
// cookie. Never persists the raw token — only its SHA-256 hash.
Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error)
// Logout replaces Handlers.Logout: deletes the session row for the
// given raw session token.
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
// ValidateSession replaces the session half of Handlers.RequireAuth /
// db.GetSessionUser. Called by the gateway on every authenticated
// request.
ValidateSession(ctx context.Context, in *ValidateSessionRequest, opts ...grpc.CallOption) (*ValidateSessionResponse, error)
// ForgotPassword replaces Handlers.ForgotPassword.
ForgotPassword(ctx context.Context, in *ForgotPasswordRequest, opts ...grpc.CallOption) (*ForgotPasswordResponse, error)
// CheckPasswordResetToken replaces the GET-time validity check in
// Handlers.ResetPasswordPage (db.PasswordResetTokenValid).
CheckPasswordResetToken(ctx context.Context, in *CheckPasswordResetTokenRequest, opts ...grpc.CallOption) (*CheckPasswordResetTokenResponse, error)
// ResetPassword replaces Handlers.ResetPassword. Also revokes every
// session for the account (db.UpdatePassword's cascade delete).
ResetPassword(ctx context.Context, in *ResetPasswordRequest, opts ...grpc.CallOption) (*ResetPasswordResponse, error)
// GetUser is used by other services (e.g. billing, when it only has a
// user_id) to resolve an email address. Never returns PasswordHash.
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error)
}
type iAMServiceClient struct {
cc grpc.ClientConnInterface
}
func NewIAMServiceClient(cc grpc.ClientConnInterface) IAMServiceClient {
return &iAMServiceClient{cc}
}
func (c *iAMServiceClient) SignUp(ctx context.Context, in *SignUpRequest, opts ...grpc.CallOption) (*SignUpResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SignUpResponse)
err := c.cc.Invoke(ctx, IAMService_SignUp_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *iAMServiceClient) ResendVerification(ctx context.Context, in *ResendVerificationRequest, opts ...grpc.CallOption) (*ResendVerificationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResendVerificationResponse)
err := c.cc.Invoke(ctx, IAMService_ResendVerification_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *iAMServiceClient) VerifyEmail(ctx context.Context, in *VerifyEmailRequest, opts ...grpc.CallOption) (*VerifyEmailResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(VerifyEmailResponse)
err := c.cc.Invoke(ctx, IAMService_VerifyEmail_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *iAMServiceClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LoginResponse)
err := c.cc.Invoke(ctx, IAMService_Login_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *iAMServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LogoutResponse)
err := c.cc.Invoke(ctx, IAMService_Logout_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *iAMServiceClient) ValidateSession(ctx context.Context, in *ValidateSessionRequest, opts ...grpc.CallOption) (*ValidateSessionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ValidateSessionResponse)
err := c.cc.Invoke(ctx, IAMService_ValidateSession_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *iAMServiceClient) ForgotPassword(ctx context.Context, in *ForgotPasswordRequest, opts ...grpc.CallOption) (*ForgotPasswordResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ForgotPasswordResponse)
err := c.cc.Invoke(ctx, IAMService_ForgotPassword_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *iAMServiceClient) CheckPasswordResetToken(ctx context.Context, in *CheckPasswordResetTokenRequest, opts ...grpc.CallOption) (*CheckPasswordResetTokenResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CheckPasswordResetTokenResponse)
err := c.cc.Invoke(ctx, IAMService_CheckPasswordResetToken_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *iAMServiceClient) ResetPassword(ctx context.Context, in *ResetPasswordRequest, opts ...grpc.CallOption) (*ResetPasswordResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResetPasswordResponse)
err := c.cc.Invoke(ctx, IAMService_ResetPassword_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *iAMServiceClient) GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(User)
err := c.cc.Invoke(ctx, IAMService_GetUser_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// IAMServiceServer is the server API for IAMService service.
// All implementations must embed UnimplementedIAMServiceServer
// for forward compatibility.
type IAMServiceServer interface {
// SignUp replaces Handlers.Signup. Validates and normalizes the email,
// hashes the password (PBKDF2, see internal/auth/password.go), creates
// the account (or silently reuses an existing unverified one — same
// anti-enumeration behavior as the monolith), issues a verification
// code and publishes an iam.verification_code.issued event for the
// notification service to deliver it. Never returns whether the email
// already existed.
SignUp(context.Context, *SignUpRequest) (*SignUpResponse, error)
// ResendVerification replaces Handlers.ResendVerification.
ResendVerification(context.Context, *ResendVerificationRequest) (*ResendVerificationResponse, error)
// VerifyEmail replaces Handlers.VerifyEmail.
VerifyEmail(context.Context, *VerifyEmailRequest) (*VerifyEmailResponse, error)
// Login replaces Handlers.Login: verifies credentials (constant-time
// against a dummy hash for unknown emails) and, on success, mints a
// session and returns its raw token for the gateway to set as a
// cookie. Never persists the raw token — only its SHA-256 hash.
Login(context.Context, *LoginRequest) (*LoginResponse, error)
// Logout replaces Handlers.Logout: deletes the session row for the
// given raw session token.
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
// ValidateSession replaces the session half of Handlers.RequireAuth /
// db.GetSessionUser. Called by the gateway on every authenticated
// request.
ValidateSession(context.Context, *ValidateSessionRequest) (*ValidateSessionResponse, error)
// ForgotPassword replaces Handlers.ForgotPassword.
ForgotPassword(context.Context, *ForgotPasswordRequest) (*ForgotPasswordResponse, error)
// CheckPasswordResetToken replaces the GET-time validity check in
// Handlers.ResetPasswordPage (db.PasswordResetTokenValid).
CheckPasswordResetToken(context.Context, *CheckPasswordResetTokenRequest) (*CheckPasswordResetTokenResponse, error)
// ResetPassword replaces Handlers.ResetPassword. Also revokes every
// session for the account (db.UpdatePassword's cascade delete).
ResetPassword(context.Context, *ResetPasswordRequest) (*ResetPasswordResponse, error)
// GetUser is used by other services (e.g. billing, when it only has a
// user_id) to resolve an email address. Never returns PasswordHash.
GetUser(context.Context, *GetUserRequest) (*User, error)
mustEmbedUnimplementedIAMServiceServer()
}
// UnimplementedIAMServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedIAMServiceServer struct{}
func (UnimplementedIAMServiceServer) SignUp(context.Context, *SignUpRequest) (*SignUpResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SignUp not implemented")
}
func (UnimplementedIAMServiceServer) ResendVerification(context.Context, *ResendVerificationRequest) (*ResendVerificationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ResendVerification not implemented")
}
func (UnimplementedIAMServiceServer) VerifyEmail(context.Context, *VerifyEmailRequest) (*VerifyEmailResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VerifyEmail not implemented")
}
func (UnimplementedIAMServiceServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Login not implemented")
}
func (UnimplementedIAMServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented")
}
func (UnimplementedIAMServiceServer) ValidateSession(context.Context, *ValidateSessionRequest) (*ValidateSessionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ValidateSession not implemented")
}
func (UnimplementedIAMServiceServer) ForgotPassword(context.Context, *ForgotPasswordRequest) (*ForgotPasswordResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ForgotPassword not implemented")
}
func (UnimplementedIAMServiceServer) CheckPasswordResetToken(context.Context, *CheckPasswordResetTokenRequest) (*CheckPasswordResetTokenResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CheckPasswordResetToken not implemented")
}
func (UnimplementedIAMServiceServer) ResetPassword(context.Context, *ResetPasswordRequest) (*ResetPasswordResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ResetPassword not implemented")
}
func (UnimplementedIAMServiceServer) GetUser(context.Context, *GetUserRequest) (*User, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUser not implemented")
}
func (UnimplementedIAMServiceServer) mustEmbedUnimplementedIAMServiceServer() {}
func (UnimplementedIAMServiceServer) testEmbeddedByValue() {}
// UnsafeIAMServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to IAMServiceServer will
// result in compilation errors.
type UnsafeIAMServiceServer interface {
mustEmbedUnimplementedIAMServiceServer()
}
func RegisterIAMServiceServer(s grpc.ServiceRegistrar, srv IAMServiceServer) {
// If the following call pancis, it indicates UnimplementedIAMServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&IAMService_ServiceDesc, srv)
}
func _IAMService_SignUp_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SignUpRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(IAMServiceServer).SignUp(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: IAMService_SignUp_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(IAMServiceServer).SignUp(ctx, req.(*SignUpRequest))
}
return interceptor(ctx, in, info, handler)
}
func _IAMService_ResendVerification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ResendVerificationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(IAMServiceServer).ResendVerification(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: IAMService_ResendVerification_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(IAMServiceServer).ResendVerification(ctx, req.(*ResendVerificationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _IAMService_VerifyEmail_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(VerifyEmailRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(IAMServiceServer).VerifyEmail(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: IAMService_VerifyEmail_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(IAMServiceServer).VerifyEmail(ctx, req.(*VerifyEmailRequest))
}
return interceptor(ctx, in, info, handler)
}
func _IAMService_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LoginRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(IAMServiceServer).Login(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: IAMService_Login_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(IAMServiceServer).Login(ctx, req.(*LoginRequest))
}
return interceptor(ctx, in, info, handler)
}
func _IAMService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LogoutRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(IAMServiceServer).Logout(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: IAMService_Logout_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(IAMServiceServer).Logout(ctx, req.(*LogoutRequest))
}
return interceptor(ctx, in, info, handler)
}
func _IAMService_ValidateSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ValidateSessionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(IAMServiceServer).ValidateSession(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: IAMService_ValidateSession_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(IAMServiceServer).ValidateSession(ctx, req.(*ValidateSessionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _IAMService_ForgotPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ForgotPasswordRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(IAMServiceServer).ForgotPassword(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: IAMService_ForgotPassword_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(IAMServiceServer).ForgotPassword(ctx, req.(*ForgotPasswordRequest))
}
return interceptor(ctx, in, info, handler)
}
func _IAMService_CheckPasswordResetToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CheckPasswordResetTokenRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(IAMServiceServer).CheckPasswordResetToken(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: IAMService_CheckPasswordResetToken_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(IAMServiceServer).CheckPasswordResetToken(ctx, req.(*CheckPasswordResetTokenRequest))
}
return interceptor(ctx, in, info, handler)
}
func _IAMService_ResetPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ResetPasswordRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(IAMServiceServer).ResetPassword(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: IAMService_ResetPassword_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(IAMServiceServer).ResetPassword(ctx, req.(*ResetPasswordRequest))
}
return interceptor(ctx, in, info, handler)
}
func _IAMService_GetUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetUserRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(IAMServiceServer).GetUser(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: IAMService_GetUser_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(IAMServiceServer).GetUser(ctx, req.(*GetUserRequest))
}
return interceptor(ctx, in, info, handler)
}
// IAMService_ServiceDesc is the grpc.ServiceDesc for IAMService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var IAMService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "enkly.iam.v1.IAMService",
HandlerType: (*IAMServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "SignUp",
Handler: _IAMService_SignUp_Handler,
},
{
MethodName: "ResendVerification",
Handler: _IAMService_ResendVerification_Handler,
},
{
MethodName: "VerifyEmail",
Handler: _IAMService_VerifyEmail_Handler,
},
{
MethodName: "Login",
Handler: _IAMService_Login_Handler,
},
{
MethodName: "Logout",
Handler: _IAMService_Logout_Handler,
},
{
MethodName: "ValidateSession",
Handler: _IAMService_ValidateSession_Handler,
},
{
MethodName: "ForgotPassword",
Handler: _IAMService_ForgotPassword_Handler,
},
{
MethodName: "CheckPasswordResetToken",
Handler: _IAMService_CheckPasswordResetToken_Handler,
},
{
MethodName: "ResetPassword",
Handler: _IAMService_ResetPassword_Handler,
},
{
MethodName: "GetUser",
Handler: _IAMService_GetUser_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "iam/v1/iam.proto",
}

View File

@@ -0,0 +1,765 @@
// Package knowledge is the Code Intelligence (RAG) bounded context:
// chunking, embedding (via the knowledge service's vLLM-backed embed client) and
// cosine-similarity search over workspace source files. System of
// record for the pgvector-backed `documents` table (see
// services/knowledge/migrations/0001_init.sql).
//
// Mirrors internal/rag/*.go. IndexDirectory (cmd/indexer) now walks the
// workspace through the workspace service instead of the local
// filesystem — see tools/indexer.
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.34.2
// protoc v3.21.12
// source: knowledge/v1/knowledge.proto
package knowledgev1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type CodeChunk struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
FilePath string `protobuf:"bytes,2,opt,name=file_path,json=filePath,proto3" json:"file_path,omitempty"`
ChunkIndex int32 `protobuf:"varint,3,opt,name=chunk_index,json=chunkIndex,proto3" json:"chunk_index,omitempty"`
Content string `protobuf:"bytes,4,opt,name=content,proto3" json:"content,omitempty"`
Distance float64 `protobuf:"fixed64,5,opt,name=distance,proto3" json:"distance,omitempty"` // cosine distance; lower = more relevant
}
func (x *CodeChunk) Reset() {
*x = CodeChunk{}
if protoimpl.UnsafeEnabled {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CodeChunk) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CodeChunk) ProtoMessage() {}
func (x *CodeChunk) ProtoReflect() protoreflect.Message {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CodeChunk.ProtoReflect.Descriptor instead.
func (*CodeChunk) Descriptor() ([]byte, []int) {
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{0}
}
func (x *CodeChunk) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
func (x *CodeChunk) GetFilePath() string {
if x != nil {
return x.FilePath
}
return ""
}
func (x *CodeChunk) GetChunkIndex() int32 {
if x != nil {
return x.ChunkIndex
}
return 0
}
func (x *CodeChunk) GetContent() string {
if x != nil {
return x.Content
}
return ""
}
func (x *CodeChunk) GetDistance() float64 {
if x != nil {
return x.Distance
}
return 0
}
type IndexFileRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
FilePath string `protobuf:"bytes,1,opt,name=file_path,json=filePath,proto3" json:"file_path,omitempty"`
Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"`
}
func (x *IndexFileRequest) Reset() {
*x = IndexFileRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *IndexFileRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IndexFileRequest) ProtoMessage() {}
func (x *IndexFileRequest) ProtoReflect() protoreflect.Message {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use IndexFileRequest.ProtoReflect.Descriptor instead.
func (*IndexFileRequest) Descriptor() ([]byte, []int) {
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{1}
}
func (x *IndexFileRequest) GetFilePath() string {
if x != nil {
return x.FilePath
}
return ""
}
func (x *IndexFileRequest) GetContent() string {
if x != nil {
return x.Content
}
return ""
}
type IndexFileResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ChunksIndexed int32 `protobuf:"varint,1,opt,name=chunks_indexed,json=chunksIndexed,proto3" json:"chunks_indexed,omitempty"`
ChunksSkipped int32 `protobuf:"varint,2,opt,name=chunks_skipped,json=chunksSkipped,proto3" json:"chunks_skipped,omitempty"` // unchanged content_hash
}
func (x *IndexFileResponse) Reset() {
*x = IndexFileResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *IndexFileResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IndexFileResponse) ProtoMessage() {}
func (x *IndexFileResponse) ProtoReflect() protoreflect.Message {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[2]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use IndexFileResponse.ProtoReflect.Descriptor instead.
func (*IndexFileResponse) Descriptor() ([]byte, []int) {
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{2}
}
func (x *IndexFileResponse) GetChunksIndexed() int32 {
if x != nil {
return x.ChunksIndexed
}
return 0
}
func (x *IndexFileResponse) GetChunksSkipped() int32 {
if x != nil {
return x.ChunksSkipped
}
return 0
}
type PruneMissingRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
KeepFilePaths []string `protobuf:"bytes,1,rep,name=keep_file_paths,json=keepFilePaths,proto3" json:"keep_file_paths,omitempty"`
}
func (x *PruneMissingRequest) Reset() {
*x = PruneMissingRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *PruneMissingRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PruneMissingRequest) ProtoMessage() {}
func (x *PruneMissingRequest) ProtoReflect() protoreflect.Message {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[3]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PruneMissingRequest.ProtoReflect.Descriptor instead.
func (*PruneMissingRequest) Descriptor() ([]byte, []int) {
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{3}
}
func (x *PruneMissingRequest) GetKeepFilePaths() []string {
if x != nil {
return x.KeepFilePaths
}
return nil
}
type PruneMissingResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
RowsDeleted int32 `protobuf:"varint,1,opt,name=rows_deleted,json=rowsDeleted,proto3" json:"rows_deleted,omitempty"`
}
func (x *PruneMissingResponse) Reset() {
*x = PruneMissingResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *PruneMissingResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PruneMissingResponse) ProtoMessage() {}
func (x *PruneMissingResponse) ProtoReflect() protoreflect.Message {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[4]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PruneMissingResponse.ProtoReflect.Descriptor instead.
func (*PruneMissingResponse) Descriptor() ([]byte, []int) {
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{4}
}
func (x *PruneMissingResponse) GetRowsDeleted() int32 {
if x != nil {
return x.RowsDeleted
}
return 0
}
type SearchRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Query string `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"`
TopK int32 `protobuf:"varint,2,opt,name=top_k,json=topK,proto3" json:"top_k,omitempty"`
}
func (x *SearchRequest) Reset() {
*x = SearchRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *SearchRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SearchRequest) ProtoMessage() {}
func (x *SearchRequest) ProtoReflect() protoreflect.Message {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[5]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SearchRequest.ProtoReflect.Descriptor instead.
func (*SearchRequest) Descriptor() ([]byte, []int) {
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{5}
}
func (x *SearchRequest) GetQuery() string {
if x != nil {
return x.Query
}
return ""
}
func (x *SearchRequest) GetTopK() int32 {
if x != nil {
return x.TopK
}
return 0
}
type SearchResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Chunks []*CodeChunk `protobuf:"bytes,1,rep,name=chunks,proto3" json:"chunks,omitempty"`
}
func (x *SearchResponse) Reset() {
*x = SearchResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *SearchResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SearchResponse) ProtoMessage() {}
func (x *SearchResponse) ProtoReflect() protoreflect.Message {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[6]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SearchResponse.ProtoReflect.Descriptor instead.
func (*SearchResponse) Descriptor() ([]byte, []int) {
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{6}
}
func (x *SearchResponse) GetChunks() []*CodeChunk {
if x != nil {
return x.Chunks
}
return nil
}
type ListIndexedFilesRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Limit int32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` // 0 = default (200)
}
func (x *ListIndexedFilesRequest) Reset() {
*x = ListIndexedFilesRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ListIndexedFilesRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListIndexedFilesRequest) ProtoMessage() {}
func (x *ListIndexedFilesRequest) ProtoReflect() protoreflect.Message {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[7]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListIndexedFilesRequest.ProtoReflect.Descriptor instead.
func (*ListIndexedFilesRequest) Descriptor() ([]byte, []int) {
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{7}
}
func (x *ListIndexedFilesRequest) GetLimit() int32 {
if x != nil {
return x.Limit
}
return 0
}
type ListIndexedFilesResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
FilePaths []string `protobuf:"bytes,1,rep,name=file_paths,json=filePaths,proto3" json:"file_paths,omitempty"`
}
func (x *ListIndexedFilesResponse) Reset() {
*x = ListIndexedFilesResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ListIndexedFilesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListIndexedFilesResponse) ProtoMessage() {}
func (x *ListIndexedFilesResponse) ProtoReflect() protoreflect.Message {
mi := &file_knowledge_v1_knowledge_proto_msgTypes[8]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListIndexedFilesResponse.ProtoReflect.Descriptor instead.
func (*ListIndexedFilesResponse) Descriptor() ([]byte, []int) {
return file_knowledge_v1_knowledge_proto_rawDescGZIP(), []int{8}
}
func (x *ListIndexedFilesResponse) GetFilePaths() []string {
if x != nil {
return x.FilePaths
}
return nil
}
var File_knowledge_v1_knowledge_proto protoreflect.FileDescriptor
var file_knowledge_v1_knowledge_proto_rawDesc = []byte{
0x0a, 0x1c, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x6b,
0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x12,
0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e,
0x76, 0x31, 0x22, 0x8f, 0x01, 0x0a, 0x09, 0x43, 0x6f, 0x64, 0x65, 0x43, 0x68, 0x75, 0x6e, 0x6b,
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64,
0x12, 0x1b, 0x0a, 0x09, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20,
0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, 0x12, 0x1f, 0x0a,
0x0b, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01,
0x28, 0x05, 0x52, 0x0a, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x18,
0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x69, 0x73, 0x74,
0x61, 0x6e, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, 0x08, 0x64, 0x69, 0x73, 0x74,
0x61, 0x6e, 0x63, 0x65, 0x22, 0x49, 0x0a, 0x10, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x46, 0x69, 0x6c,
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x69, 0x6c, 0x65,
0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c,
0x65, 0x50, 0x61, 0x74, 0x68, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x22,
0x61, 0x0a, 0x11, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x73, 0x5f, 0x69,
0x6e, 0x64, 0x65, 0x78, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x63, 0x68,
0x75, 0x6e, 0x6b, 0x73, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x63,
0x68, 0x75, 0x6e, 0x6b, 0x73, 0x5f, 0x73, 0x6b, 0x69, 0x70, 0x70, 0x65, 0x64, 0x18, 0x02, 0x20,
0x01, 0x28, 0x05, 0x52, 0x0d, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x73, 0x53, 0x6b, 0x69, 0x70, 0x70,
0x65, 0x64, 0x22, 0x3d, 0x0a, 0x13, 0x50, 0x72, 0x75, 0x6e, 0x65, 0x4d, 0x69, 0x73, 0x73, 0x69,
0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6b, 0x65, 0x65,
0x70, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x73, 0x18, 0x01, 0x20, 0x03,
0x28, 0x09, 0x52, 0x0d, 0x6b, 0x65, 0x65, 0x70, 0x46, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68,
0x73, 0x22, 0x39, 0x0a, 0x14, 0x50, 0x72, 0x75, 0x6e, 0x65, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6e,
0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x6f, 0x77,
0x73, 0x5f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52,
0x0b, 0x72, 0x6f, 0x77, 0x73, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x22, 0x3a, 0x0a, 0x0d,
0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a,
0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75,
0x65, 0x72, 0x79, 0x12, 0x13, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x5f, 0x6b, 0x18, 0x02, 0x20, 0x01,
0x28, 0x05, 0x52, 0x04, 0x74, 0x6f, 0x70, 0x4b, 0x22, 0x47, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72,
0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x06, 0x63, 0x68,
0x75, 0x6e, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x65, 0x6e, 0x6b,
0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e,
0x43, 0x6f, 0x64, 0x65, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x52, 0x06, 0x63, 0x68, 0x75, 0x6e, 0x6b,
0x73, 0x22, 0x2f, 0x0a, 0x17, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x64,
0x46, 0x69, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05,
0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x69, 0x6d,
0x69, 0x74, 0x22, 0x39, 0x0a, 0x18, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65,
0x64, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d,
0x0a, 0x0a, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x73, 0x18, 0x01, 0x20, 0x03,
0x28, 0x09, 0x52, 0x09, 0x66, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, 0x73, 0x32, 0x8f, 0x03,
0x0a, 0x10, 0x4b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69,
0x63, 0x65, 0x12, 0x58, 0x0a, 0x09, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x46, 0x69, 0x6c, 0x65, 0x12,
0x24, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67,
0x65, 0x2e, 0x76, 0x31, 0x2e, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e,
0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x49, 0x6e, 0x64, 0x65, 0x78,
0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x61, 0x0a, 0x0c,
0x50, 0x72, 0x75, 0x6e, 0x65, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x12, 0x27, 0x2e, 0x65,
0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76,
0x31, 0x2e, 0x50, 0x72, 0x75, 0x6e, 0x65, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e,
0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x75, 0x6e, 0x65,
0x4d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x4f, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x21, 0x2e, 0x65, 0x6e, 0x6b, 0x6c,
0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53,
0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x65,
0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76,
0x31, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x6d, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x64, 0x46,
0x69, 0x6c, 0x65, 0x73, 0x12, 0x2b, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f,
0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6e,
0x64, 0x65, 0x78, 0x65, 0x64, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x2c, 0x2e, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2e, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65,
0x64, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78,
0x65, 0x64, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42,
0x24, 0x5a, 0x22, 0x65, 0x6e, 0x6b, 0x6c, 0x79, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6b, 0x6e, 0x6f,
0x77, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x6b, 0x6e, 0x6f, 0x77, 0x6c, 0x65,
0x64, 0x67, 0x65, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_knowledge_v1_knowledge_proto_rawDescOnce sync.Once
file_knowledge_v1_knowledge_proto_rawDescData = file_knowledge_v1_knowledge_proto_rawDesc
)
func file_knowledge_v1_knowledge_proto_rawDescGZIP() []byte {
file_knowledge_v1_knowledge_proto_rawDescOnce.Do(func() {
file_knowledge_v1_knowledge_proto_rawDescData = protoimpl.X.CompressGZIP(file_knowledge_v1_knowledge_proto_rawDescData)
})
return file_knowledge_v1_knowledge_proto_rawDescData
}
var file_knowledge_v1_knowledge_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_knowledge_v1_knowledge_proto_goTypes = []any{
(*CodeChunk)(nil), // 0: enkly.knowledge.v1.CodeChunk
(*IndexFileRequest)(nil), // 1: enkly.knowledge.v1.IndexFileRequest
(*IndexFileResponse)(nil), // 2: enkly.knowledge.v1.IndexFileResponse
(*PruneMissingRequest)(nil), // 3: enkly.knowledge.v1.PruneMissingRequest
(*PruneMissingResponse)(nil), // 4: enkly.knowledge.v1.PruneMissingResponse
(*SearchRequest)(nil), // 5: enkly.knowledge.v1.SearchRequest
(*SearchResponse)(nil), // 6: enkly.knowledge.v1.SearchResponse
(*ListIndexedFilesRequest)(nil), // 7: enkly.knowledge.v1.ListIndexedFilesRequest
(*ListIndexedFilesResponse)(nil), // 8: enkly.knowledge.v1.ListIndexedFilesResponse
}
var file_knowledge_v1_knowledge_proto_depIdxs = []int32{
0, // 0: enkly.knowledge.v1.SearchResponse.chunks:type_name -> enkly.knowledge.v1.CodeChunk
1, // 1: enkly.knowledge.v1.KnowledgeService.IndexFile:input_type -> enkly.knowledge.v1.IndexFileRequest
3, // 2: enkly.knowledge.v1.KnowledgeService.PruneMissing:input_type -> enkly.knowledge.v1.PruneMissingRequest
5, // 3: enkly.knowledge.v1.KnowledgeService.Search:input_type -> enkly.knowledge.v1.SearchRequest
7, // 4: enkly.knowledge.v1.KnowledgeService.ListIndexedFiles:input_type -> enkly.knowledge.v1.ListIndexedFilesRequest
2, // 5: enkly.knowledge.v1.KnowledgeService.IndexFile:output_type -> enkly.knowledge.v1.IndexFileResponse
4, // 6: enkly.knowledge.v1.KnowledgeService.PruneMissing:output_type -> enkly.knowledge.v1.PruneMissingResponse
6, // 7: enkly.knowledge.v1.KnowledgeService.Search:output_type -> enkly.knowledge.v1.SearchResponse
8, // 8: enkly.knowledge.v1.KnowledgeService.ListIndexedFiles:output_type -> enkly.knowledge.v1.ListIndexedFilesResponse
5, // [5:9] is the sub-list for method output_type
1, // [1:5] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_knowledge_v1_knowledge_proto_init() }
func file_knowledge_v1_knowledge_proto_init() {
if File_knowledge_v1_knowledge_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_knowledge_v1_knowledge_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*CodeChunk); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_knowledge_v1_knowledge_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*IndexFileRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_knowledge_v1_knowledge_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*IndexFileResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_knowledge_v1_knowledge_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*PruneMissingRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_knowledge_v1_knowledge_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*PruneMissingResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_knowledge_v1_knowledge_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*SearchRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_knowledge_v1_knowledge_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*SearchResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_knowledge_v1_knowledge_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*ListIndexedFilesRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_knowledge_v1_knowledge_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*ListIndexedFilesResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_knowledge_v1_knowledge_proto_rawDesc,
NumEnums: 0,
NumMessages: 9,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_knowledge_v1_knowledge_proto_goTypes,
DependencyIndexes: file_knowledge_v1_knowledge_proto_depIdxs,
MessageInfos: file_knowledge_v1_knowledge_proto_msgTypes,
}.Build()
File_knowledge_v1_knowledge_proto = out.File
file_knowledge_v1_knowledge_proto_rawDesc = nil
file_knowledge_v1_knowledge_proto_goTypes = nil
file_knowledge_v1_knowledge_proto_depIdxs = nil
}

View File

@@ -0,0 +1,261 @@
// Package knowledge is the Code Intelligence (RAG) bounded context:
// chunking, embedding (via the knowledge service's vLLM-backed embed client) and
// cosine-similarity search over workspace source files. System of
// record for the pgvector-backed `documents` table (see
// services/knowledge/migrations/0001_init.sql).
//
// Mirrors internal/rag/*.go. IndexDirectory (cmd/indexer) now walks the
// workspace through the workspace service instead of the local
// filesystem — see tools/indexer.
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v3.21.12
// source: knowledge/v1/knowledge.proto
package knowledgev1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
KnowledgeService_IndexFile_FullMethodName = "/enkly.knowledge.v1.KnowledgeService/IndexFile"
KnowledgeService_PruneMissing_FullMethodName = "/enkly.knowledge.v1.KnowledgeService/PruneMissing"
KnowledgeService_Search_FullMethodName = "/enkly.knowledge.v1.KnowledgeService/Search"
KnowledgeService_ListIndexedFiles_FullMethodName = "/enkly.knowledge.v1.KnowledgeService/ListIndexedFiles"
)
// KnowledgeServiceClient is the client API for KnowledgeService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type KnowledgeServiceClient interface {
// IndexFile replaces rag.Store.IndexFile: chunks the given file
// content, embeds each new/changed chunk, and upserts it.
IndexFile(ctx context.Context, in *IndexFileRequest, opts ...grpc.CallOption) (*IndexFileResponse, error)
// PruneMissing replaces rag.Store.pruneMissing, called by the indexer
// after a full directory walk to drop rows for files no longer
// present.
PruneMissing(ctx context.Context, in *PruneMissingRequest, opts ...grpc.CallOption) (*PruneMissingResponse, error)
// Search replaces rag.Store.Search — the search_codebase agent tool.
Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error)
// ListIndexedFiles replaces db.ListIndexedFiles, used by the gateway's
// sidebar "indexed files" panel (Handlers.renderConversation).
ListIndexedFiles(ctx context.Context, in *ListIndexedFilesRequest, opts ...grpc.CallOption) (*ListIndexedFilesResponse, error)
}
type knowledgeServiceClient struct {
cc grpc.ClientConnInterface
}
func NewKnowledgeServiceClient(cc grpc.ClientConnInterface) KnowledgeServiceClient {
return &knowledgeServiceClient{cc}
}
func (c *knowledgeServiceClient) IndexFile(ctx context.Context, in *IndexFileRequest, opts ...grpc.CallOption) (*IndexFileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(IndexFileResponse)
err := c.cc.Invoke(ctx, KnowledgeService_IndexFile_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *knowledgeServiceClient) PruneMissing(ctx context.Context, in *PruneMissingRequest, opts ...grpc.CallOption) (*PruneMissingResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PruneMissingResponse)
err := c.cc.Invoke(ctx, KnowledgeService_PruneMissing_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *knowledgeServiceClient) Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SearchResponse)
err := c.cc.Invoke(ctx, KnowledgeService_Search_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *knowledgeServiceClient) ListIndexedFiles(ctx context.Context, in *ListIndexedFilesRequest, opts ...grpc.CallOption) (*ListIndexedFilesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListIndexedFilesResponse)
err := c.cc.Invoke(ctx, KnowledgeService_ListIndexedFiles_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// KnowledgeServiceServer is the server API for KnowledgeService service.
// All implementations must embed UnimplementedKnowledgeServiceServer
// for forward compatibility.
type KnowledgeServiceServer interface {
// IndexFile replaces rag.Store.IndexFile: chunks the given file
// content, embeds each new/changed chunk, and upserts it.
IndexFile(context.Context, *IndexFileRequest) (*IndexFileResponse, error)
// PruneMissing replaces rag.Store.pruneMissing, called by the indexer
// after a full directory walk to drop rows for files no longer
// present.
PruneMissing(context.Context, *PruneMissingRequest) (*PruneMissingResponse, error)
// Search replaces rag.Store.Search — the search_codebase agent tool.
Search(context.Context, *SearchRequest) (*SearchResponse, error)
// ListIndexedFiles replaces db.ListIndexedFiles, used by the gateway's
// sidebar "indexed files" panel (Handlers.renderConversation).
ListIndexedFiles(context.Context, *ListIndexedFilesRequest) (*ListIndexedFilesResponse, error)
mustEmbedUnimplementedKnowledgeServiceServer()
}
// UnimplementedKnowledgeServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedKnowledgeServiceServer struct{}
func (UnimplementedKnowledgeServiceServer) IndexFile(context.Context, *IndexFileRequest) (*IndexFileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method IndexFile not implemented")
}
func (UnimplementedKnowledgeServiceServer) PruneMissing(context.Context, *PruneMissingRequest) (*PruneMissingResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PruneMissing not implemented")
}
func (UnimplementedKnowledgeServiceServer) Search(context.Context, *SearchRequest) (*SearchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Search not implemented")
}
func (UnimplementedKnowledgeServiceServer) ListIndexedFiles(context.Context, *ListIndexedFilesRequest) (*ListIndexedFilesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListIndexedFiles not implemented")
}
func (UnimplementedKnowledgeServiceServer) mustEmbedUnimplementedKnowledgeServiceServer() {}
func (UnimplementedKnowledgeServiceServer) testEmbeddedByValue() {}
// UnsafeKnowledgeServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to KnowledgeServiceServer will
// result in compilation errors.
type UnsafeKnowledgeServiceServer interface {
mustEmbedUnimplementedKnowledgeServiceServer()
}
func RegisterKnowledgeServiceServer(s grpc.ServiceRegistrar, srv KnowledgeServiceServer) {
// If the following call pancis, it indicates UnimplementedKnowledgeServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&KnowledgeService_ServiceDesc, srv)
}
func _KnowledgeService_IndexFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(IndexFileRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KnowledgeServiceServer).IndexFile(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KnowledgeService_IndexFile_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KnowledgeServiceServer).IndexFile(ctx, req.(*IndexFileRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KnowledgeService_PruneMissing_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PruneMissingRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KnowledgeServiceServer).PruneMissing(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KnowledgeService_PruneMissing_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KnowledgeServiceServer).PruneMissing(ctx, req.(*PruneMissingRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KnowledgeService_Search_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SearchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KnowledgeServiceServer).Search(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KnowledgeService_Search_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KnowledgeServiceServer).Search(ctx, req.(*SearchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KnowledgeService_ListIndexedFiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListIndexedFilesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KnowledgeServiceServer).ListIndexedFiles(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KnowledgeService_ListIndexedFiles_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KnowledgeServiceServer).ListIndexedFiles(ctx, req.(*ListIndexedFilesRequest))
}
return interceptor(ctx, in, info, handler)
}
// KnowledgeService_ServiceDesc is the grpc.ServiceDesc for KnowledgeService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var KnowledgeService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "enkly.knowledge.v1.KnowledgeService",
HandlerType: (*KnowledgeServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "IndexFile",
Handler: _KnowledgeService_IndexFile_Handler,
},
{
MethodName: "PruneMissing",
Handler: _KnowledgeService_PruneMissing_Handler,
},
{
MethodName: "Search",
Handler: _KnowledgeService_Search_Handler,
},
{
MethodName: "ListIndexedFiles",
Handler: _KnowledgeService_ListIndexedFiles_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "knowledge/v1/knowledge.proto",
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,554 @@
// Package workspace is the Workspace (agent sandbox / files) bounded
// context: the single shared ENKLY_WORKSPACE directory the coding agent
// reads, writes and runs commands in, and that the "Files" panel in the
// UI lets a human upload into / download from. Every path-jailing rule
// from internal/llm/sandbox.go (ResolvePath, the bash allowlist, the
// filesystem guards) is enforced here and nowhere else — no other
// service is allowed to touch the workspace volume directly.
//
// Mirrors internal/llm/tools.go (filesystem + bash tools) and
// internal/handlers/files.go / ui.go (upload, download, tree).
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v3.21.12
// source: workspace/v1/workspace.proto
package workspacev1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
WorkspaceService_ReadFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/ReadFile"
WorkspaceService_WriteFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/WriteFile"
WorkspaceService_EditFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/EditFile"
WorkspaceService_MultiEditFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/MultiEditFile"
WorkspaceService_ListDirectory_FullMethodName = "/enkly.workspace.v1.WorkspaceService/ListDirectory"
WorkspaceService_GlobSearch_FullMethodName = "/enkly.workspace.v1.WorkspaceService/GlobSearch"
WorkspaceService_Grep_FullMethodName = "/enkly.workspace.v1.WorkspaceService/Grep"
WorkspaceService_RunBashCommand_FullMethodName = "/enkly.workspace.v1.WorkspaceService/RunBashCommand"
WorkspaceService_UploadFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/UploadFile"
WorkspaceService_DownloadFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/DownloadFile"
WorkspaceService_Tree_FullMethodName = "/enkly.workspace.v1.WorkspaceService/Tree"
WorkspaceService_StatFile_FullMethodName = "/enkly.workspace.v1.WorkspaceService/StatFile"
)
// WorkspaceServiceClient is the client API for WorkspaceService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type WorkspaceServiceClient interface {
// --- agent filesystem/bash tools (internal/llm/tools.go, sandbox.go) ---
ReadFile(ctx context.Context, in *ReadFileRequest, opts ...grpc.CallOption) (*ReadFileResponse, error)
WriteFile(ctx context.Context, in *WriteFileRequest, opts ...grpc.CallOption) (*WriteFileResponse, error)
EditFile(ctx context.Context, in *EditFileRequest, opts ...grpc.CallOption) (*EditFileResponse, error)
MultiEditFile(ctx context.Context, in *MultiEditFileRequest, opts ...grpc.CallOption) (*MultiEditFileResponse, error)
ListDirectory(ctx context.Context, in *ListDirectoryRequest, opts ...grpc.CallOption) (*ListDirectoryResponse, error)
GlobSearch(ctx context.Context, in *GlobSearchRequest, opts ...grpc.CallOption) (*GlobSearchResponse, error)
Grep(ctx context.Context, in *GrepRequest, opts ...grpc.CallOption) (*GrepResponse, error)
RunBashCommand(ctx context.Context, in *RunBashCommandRequest, opts ...grpc.CallOption) (*RunBashCommandResponse, error)
// --- human-facing files panel (internal/handlers/files.go, ui.go) ---
UploadFile(ctx context.Context, in *UploadFileRequest, opts ...grpc.CallOption) (*UploadFileResponse, error)
DownloadFile(ctx context.Context, in *DownloadFileRequest, opts ...grpc.CallOption) (*DownloadFileResponse, error)
Tree(ctx context.Context, in *TreeRequest, opts ...grpc.CallOption) (*TreeResponse, error)
StatFile(ctx context.Context, in *StatFileRequest, opts ...grpc.CallOption) (*StatFileResponse, error)
}
type workspaceServiceClient struct {
cc grpc.ClientConnInterface
}
func NewWorkspaceServiceClient(cc grpc.ClientConnInterface) WorkspaceServiceClient {
return &workspaceServiceClient{cc}
}
func (c *workspaceServiceClient) ReadFile(ctx context.Context, in *ReadFileRequest, opts ...grpc.CallOption) (*ReadFileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ReadFileResponse)
err := c.cc.Invoke(ctx, WorkspaceService_ReadFile_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *workspaceServiceClient) WriteFile(ctx context.Context, in *WriteFileRequest, opts ...grpc.CallOption) (*WriteFileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(WriteFileResponse)
err := c.cc.Invoke(ctx, WorkspaceService_WriteFile_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *workspaceServiceClient) EditFile(ctx context.Context, in *EditFileRequest, opts ...grpc.CallOption) (*EditFileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(EditFileResponse)
err := c.cc.Invoke(ctx, WorkspaceService_EditFile_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *workspaceServiceClient) MultiEditFile(ctx context.Context, in *MultiEditFileRequest, opts ...grpc.CallOption) (*MultiEditFileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MultiEditFileResponse)
err := c.cc.Invoke(ctx, WorkspaceService_MultiEditFile_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *workspaceServiceClient) ListDirectory(ctx context.Context, in *ListDirectoryRequest, opts ...grpc.CallOption) (*ListDirectoryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListDirectoryResponse)
err := c.cc.Invoke(ctx, WorkspaceService_ListDirectory_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *workspaceServiceClient) GlobSearch(ctx context.Context, in *GlobSearchRequest, opts ...grpc.CallOption) (*GlobSearchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GlobSearchResponse)
err := c.cc.Invoke(ctx, WorkspaceService_GlobSearch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *workspaceServiceClient) Grep(ctx context.Context, in *GrepRequest, opts ...grpc.CallOption) (*GrepResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GrepResponse)
err := c.cc.Invoke(ctx, WorkspaceService_Grep_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *workspaceServiceClient) RunBashCommand(ctx context.Context, in *RunBashCommandRequest, opts ...grpc.CallOption) (*RunBashCommandResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RunBashCommandResponse)
err := c.cc.Invoke(ctx, WorkspaceService_RunBashCommand_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *workspaceServiceClient) UploadFile(ctx context.Context, in *UploadFileRequest, opts ...grpc.CallOption) (*UploadFileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UploadFileResponse)
err := c.cc.Invoke(ctx, WorkspaceService_UploadFile_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *workspaceServiceClient) DownloadFile(ctx context.Context, in *DownloadFileRequest, opts ...grpc.CallOption) (*DownloadFileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DownloadFileResponse)
err := c.cc.Invoke(ctx, WorkspaceService_DownloadFile_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *workspaceServiceClient) Tree(ctx context.Context, in *TreeRequest, opts ...grpc.CallOption) (*TreeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TreeResponse)
err := c.cc.Invoke(ctx, WorkspaceService_Tree_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *workspaceServiceClient) StatFile(ctx context.Context, in *StatFileRequest, opts ...grpc.CallOption) (*StatFileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StatFileResponse)
err := c.cc.Invoke(ctx, WorkspaceService_StatFile_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// WorkspaceServiceServer is the server API for WorkspaceService service.
// All implementations must embed UnimplementedWorkspaceServiceServer
// for forward compatibility.
type WorkspaceServiceServer interface {
// --- agent filesystem/bash tools (internal/llm/tools.go, sandbox.go) ---
ReadFile(context.Context, *ReadFileRequest) (*ReadFileResponse, error)
WriteFile(context.Context, *WriteFileRequest) (*WriteFileResponse, error)
EditFile(context.Context, *EditFileRequest) (*EditFileResponse, error)
MultiEditFile(context.Context, *MultiEditFileRequest) (*MultiEditFileResponse, error)
ListDirectory(context.Context, *ListDirectoryRequest) (*ListDirectoryResponse, error)
GlobSearch(context.Context, *GlobSearchRequest) (*GlobSearchResponse, error)
Grep(context.Context, *GrepRequest) (*GrepResponse, error)
RunBashCommand(context.Context, *RunBashCommandRequest) (*RunBashCommandResponse, error)
// --- human-facing files panel (internal/handlers/files.go, ui.go) ---
UploadFile(context.Context, *UploadFileRequest) (*UploadFileResponse, error)
DownloadFile(context.Context, *DownloadFileRequest) (*DownloadFileResponse, error)
Tree(context.Context, *TreeRequest) (*TreeResponse, error)
StatFile(context.Context, *StatFileRequest) (*StatFileResponse, error)
mustEmbedUnimplementedWorkspaceServiceServer()
}
// UnimplementedWorkspaceServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedWorkspaceServiceServer struct{}
func (UnimplementedWorkspaceServiceServer) ReadFile(context.Context, *ReadFileRequest) (*ReadFileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReadFile not implemented")
}
func (UnimplementedWorkspaceServiceServer) WriteFile(context.Context, *WriteFileRequest) (*WriteFileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method WriteFile not implemented")
}
func (UnimplementedWorkspaceServiceServer) EditFile(context.Context, *EditFileRequest) (*EditFileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method EditFile not implemented")
}
func (UnimplementedWorkspaceServiceServer) MultiEditFile(context.Context, *MultiEditFileRequest) (*MultiEditFileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method MultiEditFile not implemented")
}
func (UnimplementedWorkspaceServiceServer) ListDirectory(context.Context, *ListDirectoryRequest) (*ListDirectoryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListDirectory not implemented")
}
func (UnimplementedWorkspaceServiceServer) GlobSearch(context.Context, *GlobSearchRequest) (*GlobSearchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GlobSearch not implemented")
}
func (UnimplementedWorkspaceServiceServer) Grep(context.Context, *GrepRequest) (*GrepResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Grep not implemented")
}
func (UnimplementedWorkspaceServiceServer) RunBashCommand(context.Context, *RunBashCommandRequest) (*RunBashCommandResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RunBashCommand not implemented")
}
func (UnimplementedWorkspaceServiceServer) UploadFile(context.Context, *UploadFileRequest) (*UploadFileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UploadFile not implemented")
}
func (UnimplementedWorkspaceServiceServer) DownloadFile(context.Context, *DownloadFileRequest) (*DownloadFileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DownloadFile not implemented")
}
func (UnimplementedWorkspaceServiceServer) Tree(context.Context, *TreeRequest) (*TreeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Tree not implemented")
}
func (UnimplementedWorkspaceServiceServer) StatFile(context.Context, *StatFileRequest) (*StatFileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StatFile not implemented")
}
func (UnimplementedWorkspaceServiceServer) mustEmbedUnimplementedWorkspaceServiceServer() {}
func (UnimplementedWorkspaceServiceServer) testEmbeddedByValue() {}
// UnsafeWorkspaceServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to WorkspaceServiceServer will
// result in compilation errors.
type UnsafeWorkspaceServiceServer interface {
mustEmbedUnimplementedWorkspaceServiceServer()
}
func RegisterWorkspaceServiceServer(s grpc.ServiceRegistrar, srv WorkspaceServiceServer) {
// If the following call pancis, it indicates UnimplementedWorkspaceServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&WorkspaceService_ServiceDesc, srv)
}
func _WorkspaceService_ReadFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReadFileRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WorkspaceServiceServer).ReadFile(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WorkspaceService_ReadFile_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WorkspaceServiceServer).ReadFile(ctx, req.(*ReadFileRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WorkspaceService_WriteFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(WriteFileRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WorkspaceServiceServer).WriteFile(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WorkspaceService_WriteFile_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WorkspaceServiceServer).WriteFile(ctx, req.(*WriteFileRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WorkspaceService_EditFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(EditFileRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WorkspaceServiceServer).EditFile(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WorkspaceService_EditFile_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WorkspaceServiceServer).EditFile(ctx, req.(*EditFileRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WorkspaceService_MultiEditFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MultiEditFileRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WorkspaceServiceServer).MultiEditFile(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WorkspaceService_MultiEditFile_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WorkspaceServiceServer).MultiEditFile(ctx, req.(*MultiEditFileRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WorkspaceService_ListDirectory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListDirectoryRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WorkspaceServiceServer).ListDirectory(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WorkspaceService_ListDirectory_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WorkspaceServiceServer).ListDirectory(ctx, req.(*ListDirectoryRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WorkspaceService_GlobSearch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GlobSearchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WorkspaceServiceServer).GlobSearch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WorkspaceService_GlobSearch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WorkspaceServiceServer).GlobSearch(ctx, req.(*GlobSearchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WorkspaceService_Grep_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GrepRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WorkspaceServiceServer).Grep(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WorkspaceService_Grep_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WorkspaceServiceServer).Grep(ctx, req.(*GrepRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WorkspaceService_RunBashCommand_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RunBashCommandRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WorkspaceServiceServer).RunBashCommand(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WorkspaceService_RunBashCommand_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WorkspaceServiceServer).RunBashCommand(ctx, req.(*RunBashCommandRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WorkspaceService_UploadFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UploadFileRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WorkspaceServiceServer).UploadFile(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WorkspaceService_UploadFile_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WorkspaceServiceServer).UploadFile(ctx, req.(*UploadFileRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WorkspaceService_DownloadFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DownloadFileRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WorkspaceServiceServer).DownloadFile(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WorkspaceService_DownloadFile_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WorkspaceServiceServer).DownloadFile(ctx, req.(*DownloadFileRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WorkspaceService_Tree_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TreeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WorkspaceServiceServer).Tree(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WorkspaceService_Tree_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WorkspaceServiceServer).Tree(ctx, req.(*TreeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WorkspaceService_StatFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StatFileRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WorkspaceServiceServer).StatFile(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WorkspaceService_StatFile_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WorkspaceServiceServer).StatFile(ctx, req.(*StatFileRequest))
}
return interceptor(ctx, in, info, handler)
}
// WorkspaceService_ServiceDesc is the grpc.ServiceDesc for WorkspaceService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var WorkspaceService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "enkly.workspace.v1.WorkspaceService",
HandlerType: (*WorkspaceServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ReadFile",
Handler: _WorkspaceService_ReadFile_Handler,
},
{
MethodName: "WriteFile",
Handler: _WorkspaceService_WriteFile_Handler,
},
{
MethodName: "EditFile",
Handler: _WorkspaceService_EditFile_Handler,
},
{
MethodName: "MultiEditFile",
Handler: _WorkspaceService_MultiEditFile_Handler,
},
{
MethodName: "ListDirectory",
Handler: _WorkspaceService_ListDirectory_Handler,
},
{
MethodName: "GlobSearch",
Handler: _WorkspaceService_GlobSearch_Handler,
},
{
MethodName: "Grep",
Handler: _WorkspaceService_Grep_Handler,
},
{
MethodName: "RunBashCommand",
Handler: _WorkspaceService_RunBashCommand_Handler,
},
{
MethodName: "UploadFile",
Handler: _WorkspaceService_UploadFile_Handler,
},
{
MethodName: "DownloadFile",
Handler: _WorkspaceService_DownloadFile_Handler,
},
{
MethodName: "Tree",
Handler: _WorkspaceService_Tree_Handler,
},
{
MethodName: "StatFile",
Handler: _WorkspaceService_StatFile_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "workspace/v1/workspace.proto",
}

12
go.mod Normal file
View File

@@ -0,0 +1,12 @@
module enkly
go 1.23
require (
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e
github.com/lib/pq v1.10.9
github.com/pgvector/pgvector-go v0.3.0
github.com/rabbitmq/amqp091-go v1.10.0
google.golang.org/grpc v1.68.0
google.golang.org/protobuf v1.34.2
)

20
go.sum Normal file
View File

@@ -0,0 +1,20 @@
github.com/golang/net v0.29.0 h1:Mx+h6LzLbooApFP35Nyl7cml9lqVseBibdIPtwjD37M=
github.com/golang/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
github.com/golang/sys v0.31.0 h1:Eq5bV7nV7QPJtg45rs39GBjQmAuNXx0aW0W/Bebr9Y8=
github.com/golang/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
github.com/golang/text v0.18.0 h1:ewE2C+lYYgGrAGia0P2syP9ssbUUciSvg/TGTNgDQJE=
github.com/golang/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e h1:ESHlT0RVZphh4JGBz49I5R6nTdC8Qyc08vU25GQHzzQ=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/googleapis/go-genproto v0.0.0-20240903143218-8af14fe29dc1 h1:AVDHBFR7Yj+saskSkvMh4BNLQRgf0aasVu58Y/vVA6k=
github.com/googleapis/go-genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:UUIF5TYSIIUc2HQX9xZl/bwTV3BpN7Rqxyh9dIJ65ZM=
github.com/googleapis/go-genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
github.com/grpc/grpc-go v1.68.0 h1:pdoraKWBZDoz3kz6sukUu89Phodtmn4i84kxFAaggXM=
github.com/grpc/grpc-go v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
github.com/protocolbuffers/protobuf-go v1.34.2 h1:/mKZtacKo6mtfYX8RvoyXDQFPAgeslXDSTWuxZYnewA=
github.com/protocolbuffers/protobuf-go v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=

6
k8s/00-namespace.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: enkly
labels:
app.kubernetes.io/part-of: enkly

103
k8s/01-config.yaml Normal file
View File

@@ -0,0 +1,103 @@
# Non-secret configuration shared by every service. Keys mirror the
# ENKLY_* env vars from docker-compose.yml / .env.example 1:1 so the
# application code needs zero changes — only the hostnames change,
# from docker-compose service names to in-cluster Kubernetes Service
# names (which are identical, so most values don't even change).
# NOTE: the three *_POSTGRES_DSN values are NOT here — ConfigMap data
# is static text and cannot expand $(POSTGRES_PASSWORD); each DSN is
# instead composed inline in its Deployment's env: list (see the
# per-service files), where Kubernetes does support that expansion.
apiVersion: v1
kind: ConfigMap
metadata:
name: enkly-config
namespace: enkly
data:
# --- shared ---
ENKLY_BASE_URL: "https://ai.enkly.org" # set to your real public URL / Ingress host
# Back on vLLM (per your request), still on a GPU-less cluster: both
# Deployments in 11-vllm.yaml now run vLLM's CPU backend — a custom
# build with AVX-512 disabled (registry.enkly.org/enkly/vllm-cpu),
# not vLLM's official pre-built image, after vllm-embed's init
# container confirmed this cluster's nodes actually lack avx512f —
# instead of the CUDA vllm/vllm-openai image, with no nvidia.com/gpu
# request and no GPU-pool nodeSelector/toleration. See 11-vllm.yaml's
# header for the build command and the memory-sizing fix that went
# with it (vllm-chat's original 8Gi request exceeded this cluster's
# confirmed ~7.76Gi per-node RAM outright).
# Two Deployments, two Services, same as the original GPU manifest
# (one vLLM engine process serves exactly one model — see
# pkg/vllmkit's doc comment) — unlike the brief Ollama detour this
# cluster also tried, which could multiplex both models behind one
# daemon. Internal-only ClusterIP Services this time (no public
# vllm-chat.enkly.org/vllm-embed.enkly.org Ingress) since agent and
# knowledge are the only callers and both are in-cluster.
ENKLY_VLLM_CHAT_URL: "http://vllm-chat:8000/v1"
ENKLY_VLLM_EMBED_URL: "http://vllm-embed:8000/v1"
# qwen3.5 (the original GPU deployment's model, ~35B-parameter MoE,
# served via a GPTQ-Marlin checkpoint) isn't viable here twice over:
# Marlin's INT4 kernels are CUDA-only, and 35B active-weight decode
# at CPU speeds wouldn't be usable latency regardless. Went with
# qwen2.5-1.5b-instruct rather than the 3B variant tried first —
# unquantized bf16 (CPU quantized-GPTQ/AWQ kernel support exists but
# is far less battle-tested than Marlin's GPU path, so plain bf16 is
# the safer default here) — specifically because this cluster's
# nodes only have ~7.76Gi RAM total each (see 11-vllm.yaml's header):
# the 3B model's 8Gi request alone couldn't fit on any node, full
# stop. 1.5B still supports OpenAI-style tool calling via vLLM's
# hermes parser (required by
# services/agent/internal/infrastructure/agentrun). See
# 11-vllm.yaml's resources/VLLM_CPU_KVCACHE_SPACE for how this maps
# to actual CPU/RAM, and its header for going back up to 3B or
# higher once `kubectl describe node` confirms real free headroom.
ENKLY_CHAT_MODEL: "qwen2.5-1.5b-instruct"
# Note one real behavior gap versus the original Qwen3.5 GPU
# deployment: Qwen2.5 has no equivalent to --reasoning-parser=qwen3's
# separate "thinking" trace (Message.Thinking / wire
# reasoning_content) — vLLM won't emit that field for this model, so
# Thinking comes back empty. Doesn't break chat or tool calling,
# just drops the reasoning-trace UI panel's content.
#
# Unchanged — nomic-embed-text was already the right size/cost
# trade-off on CPU (see pkg/vllmkit's DefaultEmbedModel comment and
# 11-vllm.yaml's vllm-embed Deployment), and no schema.sql
# vector(768) dimension change is needed either.
ENKLY_EMBED_MODEL: "nomic-embed-text"
# --- iam ---
ENKLY_IAM_GRPC_ADDR: ":9001"
ENKLY_SMTP_HOST: "smtp.gmail.com"
ENKLY_SMTP_PORT: "587"
# --- billing ---
ENKLY_BILLING_GRPC_ADDR: ":9002"
# --- conversation ---
ENKLY_CONVERSATION_GRPC_ADDR: ":9003"
# --- workspace ---
ENKLY_WORKSPACE_GRPC_ADDR: ":9004"
ENKLY_WORKSPACE: "/data/workspace"
ENKLY_BASH_TIMEOUT_SECONDS: "120"
# Only tools actually present in the workspace image (see its
# Dockerfile: ca-certificates + git). Extend the image before
# widening this list.
ENKLY_BASH_ALLOWLIST: "git,ls,cat,grep,find,pwd,echo"
# --- knowledge ---
ENKLY_KNOWLEDGE_GRPC_ADDR: ":9005"
# --- agent ---
ENKLY_AGENT_GRPC_ADDR: ":9006"
# --- notification ---
ENKLY_NOTIFICATION_HEALTH_ADDR: ":9007"
ENKLY_SMTP_FROM: "noreply <contact.enkly@gmail.com>"
# --- gateway ---
ENKLY_GATEWAY_HTTP_ADDR: ":8080"
ENKLY_TEMPLATES_DIR: "/app/web/templates"
ENKLY_STATIC_DIR: "/app/web/static"
# --- rabbitmq (non-secret half; the password half is in enkly-secrets) ---
RABBITMQ_DEFAULT_USER: "enkly"

67
k8s/02-secrets.yaml Normal file
View File

@@ -0,0 +1,67 @@
# TEMPLATE — replace every REPLACE_ME before applying, then either
# `kubectl apply -f 02-secrets.yaml` or, better, keep this file out of
# git and manage it with `kubectl create secret generic` / sealed-secrets
# / SOPS instead. Postgres/RabbitMQ passwords are pre-filled with the
# same "enkly" default the app ships with (see .env.example) purely so
# the stack comes up unmodified on first try — rotate them before any
# real deployment.
apiVersion: v1
kind: Secret
metadata:
name: enkly-secrets
namespace: enkly
type: Opaque
stringData:
POSTGRES_PASSWORD: "enkly"
RABBITMQ_DEFAULT_PASS: "enkly"
ENKLY_RABBITMQ_URL: "amqp://enkly:enkly@rabbitmq:5672/"
STRIPE_SECRET_KEY: "sk_test_51Sk6S809EijW3hh9YFenSRh3gT2R8QzQ1XwDb4GpgwZx7k1zADSY4ApXh92fP39Clbn5vieX5vKYRM8Ts4t878S800OJfRc1zf"
STRIPE_PUBLISH_KEY: "pk_test_51Sk6S809EijW3hh95Tu0y6PyjbYXEKNus9dPlgFFCFqm3WYNRgO8dK2qoUGSbaRT4fDCQko1wyUQ2PrqbfP1FQD100xrrf9gzY"
STRIPE_WEBHOOK_SECRET: ""
STRIPE_PRICE_BASIC: "5"
STRIPE_PRICE_PRO: "8"
STRIPE_PRICE_PREMIUM: "12"
ENKLY_SMTP_USERNAME: "contact.enkly@gmail.com"
ENKLY_SMTP_PASSWORD: "ntqjsksezwjqpcac"
# vLLM (11-vllm.yaml). ENKLY_VLLM_API_KEY is the bearer token both
# vllm-chat and vllm-embed require (--api-key) and that agent/
# knowledge authenticate with (see services/agent/cmd/server/main.go,
# services/knowledge/cmd/server/main.go — both read this exact env
# var and default to "" if it's unset, which vLLM's --api-key will
# then reject with a 401 on every single request). Generate a real
# value BEFORE applying this file — YAML stringData does not run
# shell commands, so a literal "$(openssl rand -hex 32)" string here
# would silently become the actual, non-random API key instead of
# being executed:
# kubectl create secret generic enkly-secrets -n enkly \
# --from-literal=ENKLY_VLLM_API_KEY=$(openssl rand -hex 32) --dry-run=client -o yaml
# or just replace the line below by hand with a real random string.
ENKLY_VLLM_API_KEY: "OWVmOGNiZjM1YzFjNjUxNTY0YWJlMWZmYTg0M2FiYWM5ZTkwMjI3NDBmZjAwMzVjMDdlZDUwMjNlZTg1MTk1OQ=="
# HUGGING_FACE_HUB_TOKEN is only required if Qwen/Qwen2.5-3B-Instruct
# or nomic-ai/nomic-embed-text-v1.5 is gated/private for this org's
# HF account; both are public weights today, so leave this as an
# empty string unless that changes. Re-added now that 11-vllm.yaml
# pulls straight from the Hugging Face Hub again (unlike the brief
# Ollama detour, which pulled from Ollama's own registry instead).
HUGGING_FACE_HUB_TOKEN: ""
# Pull secret for registry.enkly.org. Do NOT hand-edit this one — it's
# generated, not templated, because .dockerconfigjson must be valid
# base64 JSON. Create it with:
#
# kubectl create secret docker-registry enkly-registry \
# --namespace enkly \
# --docker-server=registry.enkly.org \
# --docker-username=<user> \
# --docker-password=<password-or-token> \
# --docker-email=<email>
#
# Every Deployment below references it as imagePullSecrets: [enkly-registry].
# If registry.enkly.org serves a self-signed/internal CA certificate,
# that trust also has to be configured at the containerd level on
# every node (/etc/containerd/certs.d/registry.enkly.org/hosts.toml) —
# that's node config, not something a k8s manifest can do; see the
# README's "Known issues to fix" section.

174
k8s/10-rabbitmq.yaml Normal file
View File

@@ -0,0 +1,174 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: rabbitmq-data, namespace: enkly}
spec:
accessModes: [ReadWriteOnce]
storageClassName: managed-nfs-storage
resources: {requests: {storage: 10Gi}}
---
# Root cause of the alarm in the pod logs: RabbitMQ's default memory
# watermark is 40% of what it detects as "total system memory". Inside
# this container that detection comes back as the *node's* memory
# (7949 MiB), not the 512Mi the container is actually capped to — so
# the watermark it set (3179 MiB) is six times higher than the limit
# Kubernetes will actually enforce. In practice that means the broker
# never throttles publishers in time and gets OOMKilled by the kubelet
# instead of gracefully blocking connections, which is what the
# watermark is supposed to prevent. Pinning the watermark explicitly,
# below the container's real memory limit, fixes that.
apiVersion: v1
kind: ConfigMap
metadata: {name: rabbitmq-conf, namespace: enkly}
data:
20-enkly.conf: |
# Keep this comfortably under the container memory limit below so
# RabbitMQ blocks publishers well before Kubernetes OOMKills the pod.
vm_memory_high_watermark.absolute = 550MB
vm_memory_calculation_strategy = allocated
disk_free_limit.absolute = 2GB
# NOTE: management_metrics_collection logs its deprecation warning on
# every boot regardless of the deprecated_features.permit setting while
# it's in the "permitted by default" phase — there is no config to
# silence it. It's informational, not actionable, until RabbitMQ moves
# it to "denied by default" in a future release. The real work item is
# migrating anything scraping the management API for metrics (Grafana,
# alerting) over to the rabbitmq_prometheus plugin before that happens.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rabbitmq
namespace: enkly
labels: {app: rabbitmq}
spec:
replicas: 1
strategy: {type: Recreate}
selector: {matchLabels: {app: rabbitmq}}
template:
metadata:
labels: {app: rabbitmq}
spec:
imagePullSecrets: [{name: enkly-registry}]
# RabbitMQ resolves hostnames during boot even with RABBITMQ_NODENAME
# pinned to rabbit@localhost (the management plugin and Erlang's
# networking stack still do their own lookups). The default resolver
# in a pod walks every *.svc.cluster.local search-domain suffix
# before giving up on each failed query, and RabbitMQ's own k8s
# deployment docs call out exactly this as a cause of ~30-50s boot
# stalls. Shortening the per-query timeout/attempts here is the
# pod-level fix that doesn't require touching cluster-wide CoreDNS.
dnsPolicy: ClusterFirst
dnsConfig:
options:
- {name: ndots, value: "2"}
- {name: timeout, value: "2"}
- {name: attempts, value: "2"}
# Runs before the rabbitmq container every single boot, sharing the
# same PVC. Deletes any leftover mnesia directory that isn't the one
# actually in use (rabbit@localhost). Those directories are what
# caused the ~120s boot time and the resulting liveness-probe kills:
# every pod restart before RABBITMQ_NODENAME was pinned minted a new
# directory named after that pod's hostname, and the entrypoint's
# ownership pass over /var/lib/rabbitmq scans all of them. This is
# idempotent — once clean, it's a no-op on every future boot, and it
# means boot time is never again at the mercy of accumulated cruft,
# so the probe timings below don't need to keep being widened.
initContainers:
- name: clean-stale-mnesia-dirs
image: rabbitmq:3.13-management-alpine
command:
- sh
- -c
- |
set -e
cd /var/lib/rabbitmq/mnesia 2>/dev/null || exit 0
for d in rabbit@*; do
[ -e "$d" ] || continue
case "$d" in
rabbit@localhost|rabbit@localhost-plugins-expand|rabbit@localhost-feature_flags)
continue ;;
esac
echo "removing stale mnesia dir: $d"
rm -rf -- "$d"
done
volumeMounts:
- {name: data, mountPath: /var/lib/rabbitmq}
containers:
- name: rabbitmq
image: rabbitmq:3.13-management-alpine
ports:
- {name: amqp, containerPort: 5672}
- {name: mgmt, containerPort: 15672}
env:
# Pins the Erlang node identity so it does NOT follow the pod's
# hostname. Without this, every rollout/reschedule gets a new
# pod name -> new node name (rabbit@<pod-name>) -> RabbitMQ
# mounts the same PVC but looks for mnesia data under a path
# for a node it's never seen before, so it reports "virgin
# node" and silently reseeds an empty vhost every time. Safe
# as a static value here because replicas is 1 and strategy is
# Recreate, so there's never two pods needing distinct identities.
- name: RABBITMQ_NODENAME
value: "rabbit@localhost"
- name: RABBITMQ_DEFAULT_USER
valueFrom: {configMapKeyRef: {name: enkly-config, key: RABBITMQ_DEFAULT_USER}}
- name: RABBITMQ_DEFAULT_PASS
valueFrom: {secretKeyRef: {name: enkly-secrets, key: RABBITMQ_DEFAULT_PASS}}
resources:
# request == limit (Guaranteed QoS): a Burstable broker pod is
# one of the kubelet's first eviction targets under node memory
# pressure, which is the last thing you want for the message
# bus. 768Mi gives the 550MB watermark above real headroom
# before the container limit, instead of the old 512Mi limit
# sitting below the watermark that was actually configured.
# Raised from 250m/500m: gives the liveness probe's
# rabbitmq-diagnostics CLI (a separate, short-lived BEAM VM
# each time it runs) enough CPU headroom to finish inside its
# 5s timeout without contending with the broker process itself.
requests: {cpu: 500m, memory: 768Mi}
limits: {cpu: 500m, memory: 768Mi}
readinessProbe:
# Switched from an exec-based "rabbitmq-diagnostics
# check_port_connectivity" to a plain TCP check on the AMQP
# port. The exec form spins up a brand-new Erlang/BEAM VM on
# every single invocation just to run the check — under this
# container's CPU limit that overhead can exceed the probe's
# own timeout even while the broker itself is completely
# healthy, which reads as a permanently-failing readiness
# probe with no corresponding error in the app's own logs.
# RabbitMQ's own Kubernetes deployment docs recommend a
# TCP-based readiness check for exactly this reason — it's
# handled by the kubelet directly with no process spawned
# inside the container, so it isn't subject to CPU contention
# with the broker or the probe's own VM startup cost.
tcpSocket: {port: amqp}
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
exec: {command: ["rabbitmq-diagnostics", "-q", "ping"]}
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 6
volumeMounts:
- {name: data, mountPath: /var/lib/rabbitmq}
- {name: conf, mountPath: /etc/rabbitmq/conf.d/20-enkly.conf, subPath: 20-enkly.conf}
volumes:
- name: data
persistentVolumeClaim: {claimName: rabbitmq-data}
- name: conf
configMap: {name: rabbitmq-conf}
---
apiVersion: v1
kind: Service
metadata:
name: rabbitmq
namespace: enkly
labels: {app: rabbitmq}
spec:
selector: {app: rabbitmq}
ports:
- {name: amqp, port: 5672, targetPort: amqp}
- {name: mgmt, port: 15672, targetPort: mgmt}

326
k8s/11-vllm.yaml Normal file
View File

@@ -0,0 +1,326 @@
# vLLM, per your request — but still on kmaster/kworker1/kworker2,
# which have no physical GPU. The original vllm-chat/vllm-embed
# (restored structurally below: two Deployments, one model each, same
# as pkg/vllmkit's doc comment describes) ran the CUDA vllm/vllm-openai
# image and requested nvidia.com/gpu: "1" — that's what made both pods
# sit Pending forever with no node able to advertise that resource.
#
# This version runs vLLM's CPU backend instead, built from source
# with AVX-512 disabled, rather than vLLM's official pre-built
# public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo image. Two real,
# observed problems drove both the image choice and the sizing below
# — not hypothetical ones:
#
# 1. vllm-embed's init container (`check-cpu-avx512` further down)
# reported no `avx512f` CPU flag on the node it landed on. That's
# consistent with VirtualBox/Vagrant nested virtualization, which
# historically doesn't expose AVX-512 to guest VMs at all — and
# this cluster's own vagrant-provisioning/ setup is exactly that.
# The pre-built image hard-requires avx512f and crashes with
# "Illegal instruction" without it; there is no runtime flag to
# turn that off, only a build-time one. So `image:` below now
# points at a custom build instead — **you must build and push it
# before applying this file**:
# git clone https://github.com/vllm-project/vllm.git && cd vllm
# docker build -f docker/Dockerfile.cpu \
# --build-arg VLLM_CPU_DISABLE_AVX512=true \
# --tag registry.enkly.org/enkly/vllm-cpu:latest \
# --target vllm-openai .
# docker push registry.enkly.org/enkly/vllm-cpu:latest
# This build falls back to plain AVX2, which is universal on any
# x86_64 node (physical or virtualized) built since ~2013 — there's
# no equivalent flag check needed for it the way avx512f needed
# one, so the init container that used to gate on avx512f is gone;
# only a lightweight informational one remains (see below).
#
# 2. vllm-chat's 8Gi memory *request* alone exceeded a single node's
# total capacity: 10-rabbitmq.yaml's memory-watermark fix already
# established each node has ~7949Mi (~7.76Gi) total RAM, so an 8Gi
# request could never be satisfied regardless of what else was
# scheduled — that's what "Pending" (not a crash) meant. Fixed two
# ways together: the chat model dropped from Qwen2.5-3B-Instruct to
# Qwen2.5-1.5B-Instruct (bf16 weights ~3GB vs. ~6GB), and
# resources.requests/limits and VLLM_CPU_KVCACHE_SPACE below were
# cut to fit comfortably inside a ~7.76Gi node alongside everything
# else already running there (4Gi/6Gi request/limit — realistic for
# ~3GB weights + 1GiB KV cache + CPU-backend runtime overhead, not
# the bare-minimum 3Gi this previously requested, which left too
# little room to the limit during startup warmup). If
# `kubectl describe node` shows more real headroom than that once
# this is stable, both the model and these numbers can be raised
# again — see the sizing comments inline.
#
# 3. The two Deployments previously shared one ReadWriteMany PVC for
# the Hugging Face cache. managed-nfs-storage (nfs-subdir-external-
# provisioner) does advertise RWX, so that likely would have bound
# — but an unverified "likely" is exactly the kind of thing that
# produces a silent Pending pod (a PVC stuck Pending blocks its
# Pod the same way insufficient node resources does, just with a
# different `kubectl describe pod` event, easy to mis-read as a
# repeat of problem #2). Split into vllm-chat-cache and
# vllm-embed-cache below, both plain ReadWriteOnce — the same
# access mode every other PVC in this project already binds with
# (10-rabbitmq.yaml, 12-databases.yaml, 23-workspace.yaml) — so
# neither Deployment's scheduling can ever depend on the other's
# PVC or on RWX support this cluster's storage class may or may
# not actually have been exercised with yet.
#
# Everything else about the original design is unchanged: two
# Deployments (one vLLM engine process serves exactly one model), the
# OpenAI-compatible /v1/chat/completions + /v1/embeddings API,
# --api-key, tool calling — so pkg/vllmkit needs no code changes,
# only this file and 01-config.yaml's model/URL values.
#
# If real GPU nodes get added to this cluster later: point `image:` on
# both Deployments back at `vllm/vllm-openai`, add back
# `nvidia.com/gpu` requests + a GPU-pool nodeSelector/toleration + an
# NVIDIA device-plugin DaemonSet, and size the model/resources up.
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: vllm-chat-cache, namespace: enkly}
spec:
# ReadWriteOnce, deliberately not a single shared ReadWriteMany PVC
# across both Deployments. nfs-subdir-external-provisioner (behind
# managed-nfs-storage) does advertise RWX, so a shared PVC would
# probably bind — but "probably" is exactly the kind of unverified
# assumption that produces a silent, hard-to-diagnose Pending pod
# (PVC stuck Pending blocks the Pod the same way insufficient node
# resources does, just with a different `kubectl describe pod`
# event). Two independent RWO claims remove that failure mode
# entirely: each is the same well-proven access mode every other
# PVC in this project already binds with (10-rabbitmq.yaml,
# 12-databases.yaml, 23-workspace.yaml), neither Deployment's
# scheduling depends on the other's PVC, and losing the (small,
# one-time-download) benefit of a shared cache between a 3GB chat
# model and a 550MB embed model costs nothing worth trading that
# reliability for.
accessModes: [ReadWriteOnce]
storageClassName: managed-nfs-storage
# Qwen2.5-1.5B-Instruct at bf16 is ~3GB on disk; sized with headroom
# to try Qwen2.5-3B-Instruct (~6GB bf16) later without resizing
# first, once `kubectl describe node` confirms real free RAM.
resources: {requests: {storage: 10Gi}}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: vllm-embed-cache, namespace: enkly}
spec:
accessModes: [ReadWriteOnce]
storageClassName: managed-nfs-storage
# nomic-embed-text-v1.5 is ~550MB on disk.
resources: {requests: {storage: 5Gi}}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: vllm-chat, namespace: enkly, labels: {app: vllm-chat}}
spec:
replicas: 1
strategy: {type: Recreate}
selector: {matchLabels: {app: vllm-chat}}
template:
metadata: {labels: {app: vllm-chat}}
spec:
imagePullSecrets: [{name: enkly-registry}]
# Deliberately NO nodeSelector/toleration/nvidia.com/gpu request
# here (unlike the original GPU version of this file) — this
# cluster has no node that could ever satisfy either.
initContainers:
- name: check-cpu-avx2
image: busybox:1.36
command: ["sh", "-c"]
args:
- |
set -e
if ! grep -q avx2 /proc/cpuinfo; then
echo "FATAL: this node's CPU has no avx2 flag." >&2
echo "registry.enkly.org/enkly/vllm-cpu (built with VLLM_CPU_DISABLE_AVX512=true) still needs AVX2 at minimum." >&2
echo "This is a much lower bar than the avx512f this cluster was already confirmed to lack, but if it's missing too, CPU inference isn't viable on this node." >&2
exit 1
fi
echo "avx2 present on this node — continuing (no avx512 requirement with this build)."
containers:
- name: vllm-chat
# Custom build, not public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo —
# see this file's header comment for why (confirmed missing
# avx512f) and the exact build/push command. This image must
# exist in your registry before applying.
image: registry.enkly.org/enkly/vllm-cpu:latest
args:
- --model=Qwen/Qwen2.5-1.5B-Instruct
- --served-model-name=$(CHAT_MODEL_NAME)
# Torch CPU float16 is unstable per vLLM's own CPU FAQ;
# bfloat16 is the documented safe default.
- --dtype=bfloat16
# Cut from 8192: the KV cache budget below is deliberately
# small to fit this node's real ~7.76Gi, and a shorter
# context is the lever that buys that back without
# dropping the KV cache to the point of thrashing.
- --max-model-len=4096
# Qwen2.5-Instruct's tool-call token format matches vLLM's
# hermes parser (the qwen3_coder parser the GPU deployment
# used is Qwen3.5-specific).
- --tool-call-parser=hermes
- --enable-auto-tool-choice
- --api-key=$(VLLM_API_KEY)
env:
- {name: CHAT_MODEL_NAME, valueFrom: {configMapKeyRef: {name: enkly-config, key: ENKLY_CHAT_MODEL}}}
- {name: VLLM_API_KEY, valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_VLLM_API_KEY}}}
- {name: HUGGING_FACE_HUB_TOKEN, valueFrom: {secretKeyRef: {name: enkly-secrets, key: HUGGING_FACE_HUB_TOKEN}}}
# Cut from 4 — this plus ~3GB of resident bf16 weights is
# what resources.requests.memory below is actually sized
# against. Raise only after `kubectl describe node` shows
# real free RAM on top of what's already running, and
# raise the memory request/limit by the same amount.
- {name: VLLM_CPU_KVCACHE_SPACE, value: "1"}
# auto binds each OpenMP thread to a physical core and
# reserves VLLM_CPU_NUM_OF_RESERVED_CPU cores for the
# front-end process — vLLM's own recommended default for
# single-rank (no tensor/pipeline parallel) deployments
# like this one.
- {name: VLLM_CPU_OMP_THREADS_BIND, value: "auto"}
- {name: VLLM_CPU_NUM_OF_RESERVED_CPU, value: "1"}
ports: [{name: http, containerPort: 8000}]
resources:
# ~3GB resident for bf16 weights + 1GiB KV cache
# (VLLM_CPU_KVCACHE_SPACE above) + CPU-backend
# interpreter/runtime overhead (torch, OpenMP worker
# threads, inference graph buffers — typically 0.5-1GiB
# for a model this size) adds up to a realistic ~4.5Gi
# steady-state, not ~4Gi — the previous 3Gi *request*
# undersold that, which doesn't block scheduling (the
# scheduler only bin-packs on the request) but left too
# little daylight to the 5Gi *limit* during the kernel
# warmup/compile the startupProbe below already budgets
# extra time for, risking an OOMKill right at startup.
# 4Gi/6Gi still sits well under this cluster's ~7.76Gi
# per-node total (10-rabbitmq.yaml) with room for whatever
# else is already scheduled there — the original 8Gi
# request couldn't fit on *any* node here, full stop,
# which is why it sat Pending; this isn't a return to
# that. Raise only after `kubectl describe node` confirms
# real headroom.
requests: {cpu: "2", memory: 4Gi}
limits: {cpu: "3", memory: 6Gi}
startupProbe:
# No model-download wait this time on a warm PVC (unlike
# the brief Ollama detour's explicit pull step) — HF Hub
# download only happens once, cached on vllm-chat-cache
# after that. What's slow instead is the CPU backend's own
# kernel warmup/compile on every start, hence the still
# generous budget.
httpGet: {path: /health, port: http}
periodSeconds: 15
failureThreshold: 60 # 15 minutes
readinessProbe:
httpGet: {path: /health, port: http}
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet: {path: /health, port: http}
periodSeconds: 20
failureThreshold: 3
volumeMounts:
- {name: hf-cache, mountPath: /root/.cache/huggingface}
# vLLM's CPU docs still recommend a real /dev/shm for its
# multiprocessing workers, same reasoning as the GPU
# version had for NCCL — just a smaller budget since
# there's no multi-GPU scratch space involved here.
- {name: dshm, mountPath: /dev/shm}
volumes:
- name: hf-cache
persistentVolumeClaim: {claimName: vllm-chat-cache}
- name: dshm
emptyDir: {medium: Memory, sizeLimit: 1Gi}
---
apiVersion: v1
kind: Service
metadata: {name: vllm-chat, namespace: enkly, labels: {app: vllm-chat}}
spec:
# ClusterIP only — no public Ingress this time. agent is the only
# caller and it's in-cluster; the original GPU manifest's
# vllm-chat.enkly.org Ingress existed for direct external testing,
# which isn't needed for this deployment to work. Add one back (same
# shape as 30-ingress.yaml) if external access is genuinely wanted.
selector: {app: vllm-chat}
ports: [{name: http, port: 8000, targetPort: http}]
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: vllm-embed, namespace: enkly, labels: {app: vllm-embed}}
spec:
replicas: 1
strategy: {type: Recreate}
selector: {matchLabels: {app: vllm-embed}}
template:
metadata: {labels: {app: vllm-embed}}
spec:
imagePullSecrets: [{name: enkly-registry}]
initContainers:
- name: check-cpu-avx2
image: busybox:1.36
command: ["sh", "-c"]
args:
- |
set -e
if ! grep -q avx2 /proc/cpuinfo; then
echo "FATAL: this node's CPU has no avx2 flag." >&2
echo "registry.enkly.org/enkly/vllm-cpu (built with VLLM_CPU_DISABLE_AVX512=true) still needs AVX2 at minimum." >&2
exit 1
fi
echo "avx2 present on this node — continuing (no avx512 requirement with this build)."
containers:
- name: vllm-embed
image: registry.enkly.org/enkly/vllm-cpu:latest
args:
- --model=nomic-ai/nomic-embed-text-v1.5
- --served-model-name=$(EMBED_MODEL_NAME)
- --task=embed
# Nomic's embedding model config uses custom (non-standard
# transformers) modeling code — needs explicit opt-in.
- --trust-remote-code
- --dtype=bfloat16
- --api-key=$(VLLM_API_KEY)
env:
- {name: EMBED_MODEL_NAME, valueFrom: {configMapKeyRef: {name: enkly-config, key: ENKLY_EMBED_MODEL}}}
- {name: VLLM_API_KEY, valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_VLLM_API_KEY}}}
- {name: HUGGING_FACE_HUB_TOKEN, valueFrom: {secretKeyRef: {name: enkly-secrets, key: HUGGING_FACE_HUB_TOKEN}}}
# A 137M-parameter embedding model needs far less KV cache
# headroom than the chat model.
- {name: VLLM_CPU_KVCACHE_SPACE, value: "1"}
- {name: VLLM_CPU_OMP_THREADS_BIND, value: "auto"}
- {name: VLLM_CPU_NUM_OF_RESERVED_CPU, value: "1"}
ports: [{name: http, containerPort: 8000}]
resources:
# Trimmed from 2Gi/4Gi for the same per-node headroom
# reason as vllm-chat above — nomic-embed-text-v1.5 is
# ~550MB on disk, so even this is generous.
requests: {cpu: "1", memory: 1Gi}
limits: {cpu: "2", memory: 2Gi}
startupProbe:
httpGet: {path: /health, port: http}
periodSeconds: 10
failureThreshold: 36 # 6 minutes — small model, no large download after first boot
readinessProbe:
httpGet: {path: /health, port: http}
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet: {path: /health, port: http}
periodSeconds: 20
failureThreshold: 3
volumeMounts:
- {name: hf-cache, mountPath: /root/.cache/huggingface}
- {name: dshm, mountPath: /dev/shm}
volumes:
- name: hf-cache
persistentVolumeClaim: {claimName: vllm-embed-cache}
- name: dshm
emptyDir: {medium: Memory, sizeLimit: 256Mi}
---
apiVersion: v1
kind: Service
metadata: {name: vllm-embed, namespace: enkly, labels: {app: vllm-embed}}
spec:
selector: {app: vllm-embed}
ports: [{name: http, port: 8000, targetPort: http}]

222
k8s/12-databases.yaml Normal file
View File

@@ -0,0 +1,222 @@
# ---------- iam-db ----------
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: iam-db-data, namespace: enkly}
spec:
accessModes: [ReadWriteOnce]
storageClassName: managed-nfs-storage
resources: {requests: {storage: 5Gi}}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: iam-db, namespace: enkly, labels: {app: iam-db}}
spec:
replicas: 1
strategy: {type: Recreate}
selector: {matchLabels: {app: iam-db}}
template:
metadata: {labels: {app: iam-db}}
spec:
imagePullSecrets: [{name: enkly-registry}]
containers:
- name: postgres
image: postgres:16-alpine
ports: [{name: pg, containerPort: 5432}]
env:
- {name: POSTGRES_USER, value: "enkly"}
- {name: POSTGRES_DB, value: "enkly_iam"}
- {name: PGDATA, value: "/var/lib/postgresql/data/pgdata"}
- name: POSTGRES_PASSWORD
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
resources:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: 500m, memory: 512Mi}
readinessProbe:
exec: {command: ["pg_isready", "-U", "enkly"]}
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
exec: {command: ["pg_isready", "-U", "enkly"]}
initialDelaySeconds: 30
periodSeconds: 15
failureThreshold: 6
volumeMounts: [{name: data, mountPath: /var/lib/postgresql/data}]
volumes:
- name: data
persistentVolumeClaim: {claimName: iam-db-data}
---
apiVersion: v1
kind: Service
metadata: {name: iam-db, namespace: enkly, labels: {app: iam-db}}
spec:
selector: {app: iam-db}
ports: [{name: pg, port: 5432, targetPort: pg}]
# ---------- billing-db ----------
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: billing-db-data, namespace: enkly}
spec:
accessModes: [ReadWriteOnce]
storageClassName: managed-nfs-storage
resources: {requests: {storage: 5Gi}}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: billing-db, namespace: enkly, labels: {app: billing-db}}
spec:
replicas: 1
strategy: {type: Recreate}
selector: {matchLabels: {app: billing-db}}
template:
metadata: {labels: {app: billing-db}}
spec:
imagePullSecrets: [{name: enkly-registry}]
containers:
- name: postgres
image: postgres:16-alpine
ports: [{name: pg, containerPort: 5432}]
env:
- {name: POSTGRES_USER, value: "enkly"}
- {name: POSTGRES_DB, value: "enkly_billing"}
- {name: PGDATA, value: "/var/lib/postgresql/data/pgdata"}
- name: POSTGRES_PASSWORD
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
resources:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: 500m, memory: 512Mi}
readinessProbe:
exec: {command: ["pg_isready", "-U", "enkly"]}
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
exec: {command: ["pg_isready", "-U", "enkly"]}
initialDelaySeconds: 30
periodSeconds: 15
failureThreshold: 6
volumeMounts: [{name: data, mountPath: /var/lib/postgresql/data}]
volumes:
- name: data
persistentVolumeClaim: {claimName: billing-db-data}
---
apiVersion: v1
kind: Service
metadata: {name: billing-db, namespace: enkly, labels: {app: billing-db}}
spec:
selector: {app: billing-db}
ports: [{name: pg, port: 5432, targetPort: pg}]
# ---------- conversation-db ----------
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: conversation-db-data, namespace: enkly}
spec:
accessModes: [ReadWriteOnce]
storageClassName: managed-nfs-storage
resources: {requests: {storage: 5Gi}}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: conversation-db, namespace: enkly, labels: {app: conversation-db}}
spec:
replicas: 1
strategy: {type: Recreate}
selector: {matchLabels: {app: conversation-db}}
template:
metadata: {labels: {app: conversation-db}}
spec:
imagePullSecrets: [{name: enkly-registry}]
containers:
- name: postgres
image: postgres:16-alpine
ports: [{name: pg, containerPort: 5432}]
env:
- {name: POSTGRES_USER, value: "enkly"}
- {name: POSTGRES_DB, value: "enkly_conversation"}
- {name: PGDATA, value: "/var/lib/postgresql/data/pgdata"}
- name: POSTGRES_PASSWORD
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
resources:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: 500m, memory: 512Mi}
readinessProbe:
exec: {command: ["pg_isready", "-U", "enkly"]}
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
exec: {command: ["pg_isready", "-U", "enkly"]}
initialDelaySeconds: 30
periodSeconds: 15
failureThreshold: 6
volumeMounts: [{name: data, mountPath: /var/lib/postgresql/data}]
volumes:
- name: data
persistentVolumeClaim: {claimName: conversation-db-data}
---
apiVersion: v1
kind: Service
metadata: {name: conversation-db, namespace: enkly, labels: {app: conversation-db}}
spec:
selector: {app: conversation-db}
ports: [{name: pg, port: 5432, targetPort: pg}]
# ---------- knowledge-db (pgvector) ----------
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: knowledge-db-data, namespace: enkly}
spec:
accessModes: [ReadWriteOnce]
storageClassName: managed-nfs-storage
resources: {requests: {storage: 10Gi}}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: knowledge-db, namespace: enkly, labels: {app: knowledge-db}}
spec:
replicas: 1
strategy: {type: Recreate}
selector: {matchLabels: {app: knowledge-db}}
template:
metadata: {labels: {app: knowledge-db}}
spec:
imagePullSecrets: [{name: enkly-registry}]
containers:
- name: postgres
image: pgvector/pgvector:pg16
ports: [{name: pg, containerPort: 5432}]
env:
- {name: POSTGRES_USER, value: "enkly"}
- {name: POSTGRES_DB, value: "enkly_knowledge"}
- {name: PGDATA, value: "/var/lib/postgresql/data/pgdata"}
- name: POSTGRES_PASSWORD
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
resources:
requests: {cpu: 200m, memory: 512Mi}
limits: {cpu: 1, memory: 1Gi}
readinessProbe:
exec: {command: ["pg_isready", "-U", "enkly"]}
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 10
livenessProbe:
exec: {command: ["pg_isready", "-U", "enkly"]}
initialDelaySeconds: 30
periodSeconds: 15
failureThreshold: 6
volumeMounts: [{name: data, mountPath: /var/lib/postgresql/data}]
volumes:
- name: data
persistentVolumeClaim: {claimName: knowledge-db-data}
---
apiVersion: v1
kind: Service
metadata: {name: knowledge-db, namespace: enkly, labels: {app: knowledge-db}}
spec:
selector: {app: knowledge-db}
ports: [{name: pg, port: 5432, targetPort: pg}]

45
k8s/20-iam.yaml Normal file
View File

@@ -0,0 +1,45 @@
apiVersion: apps/v1
kind: Deployment
metadata: {name: iam, namespace: enkly, labels: {app: iam}}
spec:
replicas: 1
selector: {matchLabels: {app: iam}}
template:
metadata: {labels: {app: iam}}
spec:
imagePullSecrets: [{name: enkly-registry}]
# Nothing in Kubernetes enforces start order between Deployments, and
# rabbitmq's own readiness probe can take a while to go green (see
# 10-rabbitmq.yaml). Without this, iam races the broker on a fresh
# rollout and can hit the same "connection refused" notification saw.
initContainers:
- name: wait-for-rabbitmq
image: busybox:1.36
command: ["sh", "-c", "until nc -z rabbitmq 5672; do echo waiting for rabbitmq; sleep 2; done"]
containers:
- name: iam
image: registry.enkly.org/enkly/iam:latest
ports: [{name: grpc, containerPort: 9001}]
envFrom: [{configMapRef: {name: enkly-config}}]
env:
- name: POSTGRES_PASSWORD
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
- {name: ENKLY_IAM_POSTGRES_DSN, value: "postgres://enkly:$(POSTGRES_PASSWORD)@iam-db:5432/enkly_iam?sslmode=disable"}
- name: ENKLY_RABBITMQ_URL
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_RABBITMQ_URL}}
- name: ENKLY_SMTP_USERNAME
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_SMTP_USERNAME}}
- name: ENKLY_SMTP_PASSWORD
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_SMTP_PASSWORD}}
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 500m, memory: 256Mi}
readinessProbe: {grpc: {port: 9001}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
livenessProbe: {grpc: {port: 9001}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
---
apiVersion: v1
kind: Service
metadata: {name: iam, namespace: enkly, labels: {app: iam}}
spec:
selector: {app: iam}
ports: [{name: grpc, port: 9001, targetPort: grpc}]

49
k8s/21-billing.yaml Normal file
View File

@@ -0,0 +1,49 @@
apiVersion: apps/v1
kind: Deployment
metadata: {name: billing, namespace: enkly, labels: {app: billing}}
spec:
replicas: 1
selector: {matchLabels: {app: billing}}
template:
metadata: {labels: {app: billing}}
spec:
imagePullSecrets: [{name: enkly-registry}]
initContainers:
- name: wait-for-rabbitmq
image: busybox:1.36
command: ["sh", "-c", "until nc -z rabbitmq 5672; do echo waiting for rabbitmq; sleep 2; done"]
containers:
- name: billing
image: registry.enkly.org/enkly/billing:latest
ports: [{name: grpc, containerPort: 9002}]
envFrom: [{configMapRef: {name: enkly-config}}]
env:
- name: POSTGRES_PASSWORD
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
- {name: ENKLY_BILLING_POSTGRES_DSN, value: "postgres://enkly:$(POSTGRES_PASSWORD)@billing-db:5432/enkly_billing?sslmode=disable"}
- name: ENKLY_RABBITMQ_URL
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_RABBITMQ_URL}}
- name: STRIPE_SECRET_KEY
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_SECRET_KEY}}
- name: STRIPE_PUBLISH_KEY
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_PUBLISH_KEY}}
- name: STRIPE_WEBHOOK_SECRET
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_WEBHOOK_SECRET}}
- name: STRIPE_PRICE_BASIC
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_PRICE_BASIC}}
- name: STRIPE_PRICE_PRO
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_PRICE_PRO}}
- name: STRIPE_PRICE_PREMIUM
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_PRICE_PREMIUM}}
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 500m, memory: 256Mi}
readinessProbe: {grpc: {port: 9002}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
livenessProbe: {grpc: {port: 9002}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
---
apiVersion: v1
kind: Service
metadata: {name: billing, namespace: enkly, labels: {app: billing}}
spec:
selector: {app: billing}
ports: [{name: grpc, port: 9002, targetPort: grpc}]

37
k8s/22-conversation.yaml Normal file
View File

@@ -0,0 +1,37 @@
apiVersion: apps/v1
kind: Deployment
metadata: {name: conversation, namespace: enkly, labels: {app: conversation}}
spec:
replicas: 1
selector: {matchLabels: {app: conversation}}
template:
metadata: {labels: {app: conversation}}
spec:
imagePullSecrets: [{name: enkly-registry}]
initContainers:
- name: wait-for-rabbitmq
image: busybox:1.36
command: ["sh", "-c", "until nc -z rabbitmq 5672; do echo waiting for rabbitmq; sleep 2; done"]
containers:
- name: conversation
image: registry.enkly.org/enkly/conversation:latest
ports: [{name: grpc, containerPort: 9003}]
envFrom: [{configMapRef: {name: enkly-config}}]
env:
- name: POSTGRES_PASSWORD
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
- {name: ENKLY_CONVERSATION_POSTGRES_DSN, value: "postgres://enkly:$(POSTGRES_PASSWORD)@conversation-db:5432/enkly_conversation?sslmode=disable"}
- name: ENKLY_RABBITMQ_URL
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_RABBITMQ_URL}}
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 500m, memory: 256Mi}
readinessProbe: {grpc: {port: 9003}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
livenessProbe: {grpc: {port: 9003}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
---
apiVersion: v1
kind: Service
metadata: {name: conversation, namespace: enkly, labels: {app: conversation}}
spec:
selector: {app: conversation}
ports: [{name: grpc, port: 9003, targetPort: grpc}]

45
k8s/23-workspace.yaml Normal file
View File

@@ -0,0 +1,45 @@
# Matches docker-compose's workspace-data volume: a PVC mounted at
# /data/workspace. Single replica + Recreate, same reasoning as the
# databases — this is where run_bash_command actually executes, so a
# second concurrent pod writing the same tree would be worse than a
# brief restart gap.
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: workspace-data, namespace: enkly}
spec:
accessModes: [ReadWriteOnce]
storageClassName: managed-nfs-storage
resources: {requests: {storage: 10Gi}}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: workspace, namespace: enkly, labels: {app: workspace}}
spec:
replicas: 1
strategy: {type: Recreate}
selector: {matchLabels: {app: workspace}}
template:
metadata: {labels: {app: workspace}}
spec:
imagePullSecrets: [{name: enkly-registry}]
containers:
- name: workspace
image: registry.enkly.org/enkly/workspace:latest
ports: [{name: grpc, containerPort: 9004}]
envFrom: [{configMapRef: {name: enkly-config}}]
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: 1, memory: 512Mi}
readinessProbe: {grpc: {port: 9004}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
livenessProbe: {grpc: {port: 9004}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
volumeMounts: [{name: data, mountPath: /data/workspace}]
volumes:
- name: data
persistentVolumeClaim: {claimName: workspace-data}
---
apiVersion: v1
kind: Service
metadata: {name: workspace, namespace: enkly, labels: {app: workspace}}
spec:
selector: {app: workspace}
ports: [{name: grpc, port: 9004, targetPort: grpc}]

36
k8s/24-knowledge.yaml Normal file
View File

@@ -0,0 +1,36 @@
apiVersion: apps/v1
kind: Deployment
metadata: {name: knowledge, namespace: enkly, labels: {app: knowledge}}
spec:
replicas: 1
selector: {matchLabels: {app: knowledge}}
template:
metadata: {labels: {app: knowledge}}
spec:
imagePullSecrets: [{name: enkly-registry}]
containers:
- name: knowledge
image: registry.enkly.org/enkly/knowledge:latest
ports: [{name: grpc, containerPort: 9005}]
envFrom: [{configMapRef: {name: enkly-config}}]
env:
- name: POSTGRES_PASSWORD
valueFrom: {secretKeyRef: {name: enkly-secrets, key: POSTGRES_PASSWORD}}
- {name: ENKLY_KNOWLEDGE_POSTGRES_DSN, value: "postgres://enkly:$(POSTGRES_PASSWORD)@knowledge-db:5432/enkly_knowledge?sslmode=disable"}
# Bearer token knowledge authenticates to vllm-embed with
# (see services/knowledge/cmd/server/main.go) — without
# this, embedding calls 401 against vllm-embed's --api-key.
- name: ENKLY_VLLM_API_KEY
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_VLLM_API_KEY}}
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: 1, memory: 512Mi}
readinessProbe: {grpc: {port: 9005}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
livenessProbe: {grpc: {port: 9005}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
---
apiVersion: v1
kind: Service
metadata: {name: knowledge, namespace: enkly, labels: {app: knowledge}}
spec:
selector: {app: knowledge}
ports: [{name: grpc, port: 9005, targetPort: grpc}]

42
k8s/25-agent.yaml Normal file
View File

@@ -0,0 +1,42 @@
# ENKLY_WORKSPACE_GRPC_ADDR here means something different from the
# same-named var on the workspace Deployment: there it's "what address
# do I listen on", here it's "what address do I dial" (agent is a
# gRPC client of workspace/knowledge/billing/conversation). That's how
# docker-compose.yml defines it too — kept identical on purpose.
apiVersion: apps/v1
kind: Deployment
metadata: {name: agent, namespace: enkly, labels: {app: agent}}
spec:
replicas: 1
selector: {matchLabels: {app: agent}}
template:
metadata: {labels: {app: agent}}
spec:
imagePullSecrets: [{name: enkly-registry}]
containers:
- name: agent
image: registry.enkly.org/enkly/agent:latest
ports: [{name: grpc, containerPort: 9006}]
envFrom: [{configMapRef: {name: enkly-config}}]
env:
- {name: ENKLY_WORKSPACE_GRPC_ADDR, value: "workspace:9004"}
- {name: ENKLY_KNOWLEDGE_GRPC_ADDR, value: "knowledge:9005"}
- {name: ENKLY_BILLING_GRPC_ADDR, value: "billing:9002"}
- {name: ENKLY_CONVERSATION_GRPC_ADDR, value: "conversation:9003"}
# Bearer token agent authenticates to vllm-chat/vllm-embed
# with (see services/agent/cmd/server/main.go) — without
# this, chat/tool-calling calls 401 against vLLM's --api-key.
- name: ENKLY_VLLM_API_KEY
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_VLLM_API_KEY}}
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: 1, memory: 512Mi}
readinessProbe: {grpc: {port: 9006}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
livenessProbe: {grpc: {port: 9006}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
---
apiVersion: v1
kind: Service
metadata: {name: agent, namespace: enkly, labels: {app: agent}}
spec:
selector: {app: agent}
ports: [{name: grpc, port: 9006, targetPort: grpc}]

38
k8s/26-notification.yaml Normal file
View File

@@ -0,0 +1,38 @@
apiVersion: apps/v1
kind: Deployment
metadata: {name: notification, namespace: enkly, labels: {app: notification}}
spec:
replicas: 1
selector: {matchLabels: {app: notification}}
template:
metadata: {labels: {app: notification}}
spec:
imagePullSecrets: [{name: enkly-registry}]
initContainers:
- name: wait-for-rabbitmq
image: busybox:1.36
command: ["sh", "-c", "until nc -z rabbitmq 5672; do echo waiting for rabbitmq; sleep 2; done"]
containers:
- name: notification
image: registry.enkly.org/enkly/notification:latest
ports: [{name: http, containerPort: 9007}]
envFrom: [{configMapRef: {name: enkly-config}}]
env:
- name: ENKLY_RABBITMQ_URL
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_RABBITMQ_URL}}
- name: ENKLY_SMTP_USERNAME
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_SMTP_USERNAME}}
- name: ENKLY_SMTP_PASSWORD
valueFrom: {secretKeyRef: {name: enkly-secrets, key: ENKLY_SMTP_PASSWORD}}
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 300m, memory: 192Mi}
readinessProbe: {httpGet: {path: /healthz, port: http}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
livenessProbe: {httpGet: {path: /healthz, port: http}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
---
apiVersion: v1
kind: Service
metadata: {name: notification, namespace: enkly, labels: {app: notification}}
spec:
selector: {app: notification}
ports: [{name: http, port: 9007, targetPort: http}]

36
k8s/27-gateway.yaml Normal file
View File

@@ -0,0 +1,36 @@
apiVersion: apps/v1
kind: Deployment
metadata: {name: gateway, namespace: enkly, labels: {app: gateway}}
spec:
replicas: 2
selector: {matchLabels: {app: gateway}}
template:
metadata: {labels: {app: gateway}}
spec:
imagePullSecrets: [{name: enkly-registry}]
containers:
- name: gateway
image: registry.enkly.org/enkly/gateway:latest
ports: [{name: http, containerPort: 8080}]
envFrom: [{configMapRef: {name: enkly-config}}]
env:
- {name: ENKLY_IAM_GRPC_ADDR, value: "iam:9001"}
- {name: ENKLY_BILLING_GRPC_ADDR, value: "billing:9002"}
- {name: ENKLY_CONVERSATION_GRPC_ADDR, value: "conversation:9003"}
- {name: ENKLY_WORKSPACE_GRPC_ADDR, value: "workspace:9004"}
- {name: ENKLY_KNOWLEDGE_GRPC_ADDR, value: "knowledge:9005"}
- {name: ENKLY_AGENT_GRPC_ADDR, value: "agent:9006"}
- name: STRIPE_PUBLISH_KEY
valueFrom: {secretKeyRef: {name: enkly-secrets, key: STRIPE_PUBLISH_KEY}}
resources:
requests: {cpu: 100m, memory: 96Mi}
limits: {cpu: 500m, memory: 256Mi}
readinessProbe: {httpGet: {path: /healthz, port: http}, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 10}
livenessProbe: {httpGet: {path: /healthz, port: http}, initialDelaySeconds: 15, periodSeconds: 20, failureThreshold: 6}
---
apiVersion: v1
kind: Service
metadata: {name: gateway, namespace: enkly, labels: {app: gateway}}
spec:
selector: {app: gateway}
ports: [{name: http, port: 8080, targetPort: http}]

40
k8s/30-ingress.yaml Normal file
View File

@@ -0,0 +1,40 @@
# Requires an ingress controller (e.g. ingress-nginx) already running
# in the cluster — `kubectl get nodes` output didn't show one and
# none was mentioned, so install one first:
# kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.3/deploy/static/provider/baremetal/deploy.yaml
# If you'd rather not run an ingress controller, delete this file and
# uncomment the NodePort Service below instead.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gateway
namespace: enkly
annotations:
cert-manager.io/cluster-issuer: production
nginx.ingress.kubernetes.io/proxy-body-size: "25m" # chat file/photo attachments
nginx.ingress.kubernetes.io/proxy-read-timeout: "300" # long-running agent/chat responses
spec:
ingressClassName: nginx
tls:
- hosts:
- ai.enkly.org # match ENKLY_BASE_URL in 01-config.yaml, or set to your real domain
secretName: ai-enkly-org
rules:
- host: ai.enkly.org
http:
paths:
- path: /
pathType: Prefix
backend:
service: {name: gateway, port: {number: 8080}}
---
# Bare-metal fallback with no ingress controller installed: exposes
# the gateway directly on every node's IP at a fixed high port. Apply
# this INSTEAD OF (not in addition to) the Ingress above if preferred.
# apiVersion: v1
# kind: Service
# metadata: {name: gateway-nodeport, namespace: enkly}
# spec:
# type: NodePort
# selector: {app: gateway}
# ports: [{name: http, port: 8080, targetPort: http, nodePort: 30080}]

222
k8s/README.md Normal file
View File

@@ -0,0 +1,222 @@
# enkly-ai — Kubernetes deployment
Plain manifests (no Helm) for the 8 Go microservices + RabbitMQ + 4
Postgres databases + two CPU-only vLLM inference servers, for the
`kmaster` / `kworker1` / `kworker2` cluster described in
`docker-compose.yml`. One file per concern, applied individually or
via `kustomization.yaml`.
## vLLM on a cluster with no GPU
An earlier version of this manifest set ran `vllm-chat`/`vllm-embed`
on the CUDA `vllm/vllm-openai` image, each requesting
`nvidia.com/gpu: "1"`. **This cluster has no physical GPU on any of
its three nodes.** `nvidia.com/gpu: "1"` is not a resource any node
here can ever advertise — with no node reporting it, both pods sat
`Pending` permanently (`Insufficient nvidia.com/gpu`), not just slow
to schedule.
`11-vllm.yaml` runs vLLM's CPU backend instead — same two Deployments,
same OpenAI-compatible API, same `--api-key` enforcement, no
application code changes. Two real, *observed* problems (not just
theoretical GPU-vs-CPU ones) shaped the rest of the fix:
1. **`avx512f` genuinely missing on this cluster's nodes.** vLLM's
official pre-built CPU image
(`public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo`) hard-requires
that flag and crashes with `Illegal instruction` without it — and
an init container added to both Deployments confirmed it's absent
here, consistent with VirtualBox/Vagrant nested virtualization
(this project's own `vagrant-provisioning/` setup) historically not
exposing AVX-512 to guest VMs at all. There's no runtime flag for
this, only a build-time one, so `image:` on both Deployments now
points at a **custom build you must create first**:
`registry.enkly.org/enkly/vllm-cpu:latest`, built with
`--build-arg VLLM_CPU_DISABLE_AVX512=true` — see `11-vllm.yaml`'s
header for the exact command. That build falls back to plain AVX2,
universal on any x86_64 node since ~2013, so the init container now
only checks for that (a much lower, already-satisfied bar) instead
of gating on `avx512f`.
2. **The chat model's memory request alone exceeded a node's total
RAM.** `10-rabbitmq.yaml`'s memory-watermark fix already
established each node has ~7949Mi (~7.76Gi) total — `vllm-chat`'s
original `8Gi` request could never be satisfied by *any* node here,
which is exactly why it sat `Pending` instead of crashing. Fixed by
dropping the chat model to `Qwen/Qwen2.5-1.5B-Instruct` (~3GB bf16
vs. the 3B variant's ~6GB) and cutting
`resources`/`VLLM_CPU_KVCACHE_SPACE`/`--max-model-len` on both
Deployments to fit comfortably inside ~7.76Gi alongside everything
else already running on that node (`vllm-chat` requests `4Gi`/limits
`6Gi` — sized to the realistic weights + KV-cache + runtime-overhead
total, not the bare minimum, so there's headroom left to the limit
during startup warmup).
3. **A shared `ReadWriteMany` PVC was an unverified assumption, not a
confirmed fix.** `vllm-chat` and `vllm-embed` originally bind-mounted
one PVC for their Hugging Face cache. `managed-nfs-storage`
(`nfs-subdir-external-provisioner`) does advertise RWX, so that
probably binds fine — but "probably" produces exactly the kind of
pod that's silently stuck `Pending` on an unbound PVC, easy to
mis-diagnose as a repeat of the GPU/memory issues above. Split into
`vllm-chat-cache` and `vllm-embed-cache`, both plain `ReadWriteOnce`
— the same access mode every other PVC in this project already binds
with — so neither Deployment's scheduling can depend on the other's
claim or on RWX ever having been exercised here.
If real GPU nodes get added to this cluster later: point `image:` on
both Deployments back at `vllm/vllm-openai`, add back
`nvidia.com/gpu` requests + a GPU-pool `nodeSelector`/`toleration` +
an NVIDIA device-plugin DaemonSet, and size the model/resources back
up.
## Other issues found in the manifest set and how they were fixed here
1. **Postgres on NFS-backed PVCs fails `initdb`.**
`nfs-subdir-external-provisioner` volumes aren't always empty at
the Postgres data-dir mount point, and `initdb` refuses to
initialize a non-empty directory. All four databases set
`PGDATA=/var/lib/postgresql/data/pgdata` (a subdirectory of the
mount) to sidestep this — see `12-databases.yaml`.
2. **`agent`/`knowledge` never received the vLLM API key.** Both
services' own code (`services/agent/cmd/server/main.go`,
`services/knowledge/cmd/server/main.go`) reads
`ENKLY_VLLM_API_KEY` from the environment and defaults to `""` if
unset — but neither `24-knowledge.yaml` nor `25-agent.yaml` sourced
it from `enkly-secrets`. Since `11-vllm.yaml` starts both vLLM
servers with `--api-key=$(VLLM_API_KEY)`, every chat/embedding call
from these two services would get rejected with `401`. Both files
now set `ENKLY_VLLM_API_KEY` from `secretKeyRef: {name:
enkly-secrets, key: ENKLY_VLLM_API_KEY}`.
3. **`02-secrets.yaml` had what read as live credentials committed as
if they were placeholders.** `STRIPE_SECRET_KEY`/
`STRIPE_PUBLISH_KEY` were filled with what look like real Stripe
test-mode keys, and `ENKLY_SMTP_USERNAME`/`ENKLY_SMTP_PASSWORD`
held a real-looking Gmail address and a 16-character app password.
**Rotate the Gmail app password and the Stripe keys if this file
was ever pushed anywhere outside a local sandbox** — replacing the
values here doesn't undo prior exposure. Replace all four with
your own before applying. Separately, `ENKLY_VLLM_API_KEY` should
be a real random value before applying — YAML `stringData` doesn't
run shell commands, so a literal `"$(openssl rand -hex 32)"` string
would become the actual, non-random key; see that key's comment in
`02-secrets.yaml` for the correct one-liner.
4. **Registry auth/TLS is cluster-level, not manifest-level.** Every
Deployment references `imagePullSecrets: [enkly-registry]`, but
that Secret must be created out-of-band (real credentials can't
live in a file meant for a repo — see `02-secrets.yaml`'s header
comment). Likewise, if `registry.enkly.org` serves a self-signed or
internal CA cert, every node's containerd needs that CA trusted via
`/etc/containerd/certs.d/registry.enkly.org/hosts.toml` — that's
node provisioning, outside what any Kubernetes manifest can
express.
## Layout
| File | What |
|---|---|
| `00-namespace.yaml` | the `enkly` namespace |
| `01-config.yaml` | non-secret `ENKLY_*` config, shared by every service |
| `02-secrets.yaml` | **template** — DB/AMQP/Stripe/SMTP/HF secret + instructions for the registry pull secret |
| `10-rabbitmq.yaml` | RabbitMQ, with a PVC (so broker state survives a pod restart) |
| `11-vllm.yaml` | vLLM chat + embed Deployments/Services, CPU backend (no GPU), each with its own RWO Hugging Face cache PVC — see above |
| `12-databases.yaml` | iam-db, billing-db, conversation-db, knowledge-db (pgvector), each with its own PVC |
| `20`–`27` | iam, billing, conversation, workspace, knowledge, agent, notification, gateway |
| `30-ingress.yaml` | Ingress for the gateway (+ commented NodePort fallback) |
| `kustomization.yaml` | ties the above together for `kubectl apply -k .` |
Every Deployment's env vars are a 1:1 mapping from
`docker-compose.yml` — service hostnames are identical to the compose
service names, so the only real change the application sees is DNS
resolving inside a namespace instead of a compose network.
## Deploy
Two things have to be true *before* any apply, because nothing in
Kubernetes can discover them on its own: your image registry has to
have the 8 service images, and your real Stripe/SMTP credentials have
to come from you.
```bash
# 1. Build & push the 8 images
REG=registry.enkly.org/enkly
for svc in iam billing conversation workspace knowledge agent notification gateway; do
docker build -f services/$svc/Dockerfile -t $REG/$svc:latest .
docker push $REG/$svc:latest
done
# 2. Registry pull secret (every Deployment references this)
kubectl create secret docker-registry enkly-registry -n enkly \
--docker-server=registry.enkly.org \
--docker-username=<user> --docker-password=<token> --docker-email=<email>
# 3. Edit 02-secrets.yaml: fill in STRIPE_*/ENKLY_SMTP_* with your own
# rotated values (see issue #3 above), and generate a real
# ENKLY_VLLM_API_KEY (the file documents the exact one-liner).
# 4. Build & push the CPU-only, no-AVX512 vLLM image — this cluster's
# nodes were confirmed to lack avx512f, so the official pre-built
# image (public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo) will crash
# with "Illegal instruction"; 11-vllm.yaml's image: already points
# at the custom tag this produces:
git clone https://github.com/vllm-project/vllm.git /tmp/vllm-build && cd /tmp/vllm-build
docker build -f docker/Dockerfile.cpu \
--build-arg VLLM_CPU_DISABLE_AVX512=true \
--tag registry.enkly.org/enkly/vllm-cpu:latest \
--target vllm-openai .
docker push registry.enkly.org/enkly/vllm-cpu:latest
# 5. Apply everything
kubectl apply -k k8s/
# 6. Watch the rollout — first boot downloads ~3.5GB of Hugging Face
# weights total onto vllm-chat-cache/vllm-embed-cache, so the
# startupProbe budget is generous.
kubectl -n enkly get pods -w
kubectl -n enkly rollout status deploy/vllm-chat --timeout=15m
kubectl -n enkly rollout status deploy/vllm-embed --timeout=15m
kubectl -n enkly rollout status deploy/gateway
```
Then point DNS/`/etc/hosts` at the Ingress controller's address for
the host set in `30-ingress.yaml` / `ENKLY_BASE_URL` in
`01-config.yaml`, or use the commented NodePort Service in
`30-ingress.yaml` if no ingress controller is installed
(`kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.3/deploy/static/provider/baremetal/deploy.yaml`
installs one on bare metal).
### Verify the LLM path specifically
```bash
kubectl -n enkly logs deploy/vllm-chat -f # watch the HF download + CPU backend warmup
kubectl -n enkly logs deploy/vllm-embed -f
kubectl -n enkly port-forward svc/gateway 8080:8080 # then run a chat turn end to end
```
If a pod is stuck in `Init:Error` / `Init:CrashLoopBackOff`, that's
the `check-cpu-avx2` init container — this cluster already needed the
custom no-AVX512 image (see above), so seeing this now would mean a
node lacks even AVX2, which no mainstream x86_64 CPU built since
~2013 should be missing; read its log for which node and double-check
`lscpu` there directly. If instead the main `vllm-chat`/`vllm-embed`
container itself is crashing after the init container passed, check
for `OOMKilled` in `kubectl -n enkly describe pod` specifically (this
cluster's nodes only have ~7.76Gi RAM total each — raise
`resources.limits.memory` cautiously, if at all, and prefer lowering
`VLLM_CPU_KVCACHE_SPACE` or `--max-model-len` instead) over assuming
it's a repeat of the AVX-512 issue.
## Scaling beyond the defaults
`iam`, `billing`, `conversation`, `knowledge`, `agent`, `notification`
and `gateway` are stateless and safe to scale (`kubectl -n enkly scale
deploy/gateway --replicas=3`). `workspace`, the four Postgres
Deployments, `vllm-chat` and `vllm-embed` are all pinned to
`replicas: 1` with `strategy: Recreate` because each owns a
`ReadWriteOnce` PVC — don't raise their replica counts without first
either moving to a clustered/replicated backend (e.g. Patroni for
Postgres) or, for the vLLM pair specifically, converting
`vllm-chat-cache`/`vllm-embed-cache` to `ReadWriteMany` (cheap to try:
`managed-nfs-storage` already advertises it) and switching
`strategy.type` to `RollingUpdate`. Either way, remember each replica
is a full, independent CPU-bound process (no GPU to share), so scaling
trades throughput for a proportional increase in CPU/RAM footprint,
not a free win the way request-level load balancing sometimes is.

19
k8s/kustomization.yaml Normal file
View File

@@ -0,0 +1,19 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: enkly
resources:
- 00-namespace.yaml
- 01-config.yaml
- 02-secrets.yaml
- 10-rabbitmq.yaml
- 11-vllm.yaml
- 12-databases.yaml
- 20-iam.yaml
- 21-billing.yaml
- 22-conversation.yaml
- 23-workspace.yaml
- 24-knowledge.yaml
- 25-agent.yaml
- 26-notification.yaml
- 27-gateway.yaml
- 30-ingress.yaml

37
pkg/authsupport/csrf.go Normal file
View File

@@ -0,0 +1,37 @@
package authsupport
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
)
// csrfTokenBytes is the amount of entropy in a CSRF token before
// encoding. This isn't a secret that needs to resist offline brute
// force the way a password hash does — it just needs to be
// unguessable to a third-party site, so 128 bits is comfortably enough.
const csrfTokenBytes = 16
// NewCSRFToken returns a fresh, URL-safe random token suitable for the
// double-submit-cookie pattern: the same value is set as a cookie and
// embedded as a hidden form field, and a request is rejected unless the
// two match (see VerifyCSRF). Unlike session/verification tokens, the
// raw value itself is what's compared — there's no server-side lookup,
// so no hash-at-rest step is needed here.
func NewCSRFToken() (string, error) {
b := make([]byte, csrfTokenBytes)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("auth: generate csrf token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// VerifyCSRF reports whether a cookie value and a submitted form value
// match, in constant time. Both empty is never valid.
func VerifyCSRF(cookieValue, formValue string) bool {
if cookieValue == "" || formValue == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(cookieValue), []byte(formValue)) == 1
}

110
pkg/authsupport/password.go Normal file
View File

@@ -0,0 +1,110 @@
// Package auth holds enkly's password hashing, token generation, and
// session/rate-limiting primitives.
//
// Password hashing deliberately avoids golang.org/x/crypto/bcrypt: this
// module has no third-party dependencies beyond lib/pq and gomarkdown
// (see go.mod), and pulling in x/crypto just for bcrypt isn't worth the
// extra supply-chain surface when the stdlib's crypto/hmac + crypto/sha256
// are enough to implement PBKDF2 (RFC 8018) directly — a well-defined,
// widely reviewed construction, not a home-grown cipher.
package authsupport
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"strconv"
"strings"
)
// pbkdf2Iterations follows OWASP's 2023 password-storage cheat sheet
// recommendation for PBKDF2-HMAC-SHA256 (>= 600,000 was later raised;
// 210,000 is the still-current floor for SHA256 specifically). Bump this
// if hashing becomes cheap enough to be a problem — old hashes keep
// verifying fine since the iteration count travels with the hash.
const pbkdf2Iterations = 210_000
const (
saltLen = 16 // bytes
keyLen = 32 // bytes (SHA-256 output size)
)
// pbkdf2 derives a keyLen-byte key from password+salt using HMAC-SHA256
// as the PRF, per RFC 8018 section 5.2.
func pbkdf2(password, salt []byte, iterations, length int) []byte {
prf := hmac.New(sha256.New, password)
hLen := prf.Size()
numBlocks := (length + hLen - 1) / hLen
var derived []byte
buf := make([]byte, 4)
for block := 1; block <= numBlocks; block++ {
buf[0] = byte(block >> 24)
buf[1] = byte(block >> 16)
buf[2] = byte(block >> 8)
buf[3] = byte(block)
prf.Reset()
prf.Write(salt)
prf.Write(buf)
u := prf.Sum(nil)
t := make([]byte, len(u))
copy(t, u)
for i := 1; i < iterations; i++ {
prf.Reset()
prf.Write(u)
u = prf.Sum(nil)
for j := range t {
t[j] ^= u[j]
}
}
derived = append(derived, t...)
}
return derived[:length]
}
// HashPassword returns an encoded hash of the form
// "pbkdf2-sha256$<iterations>$<base64 salt>$<base64 hash>", suitable for
// storing directly in users.password_hash.
func HashPassword(password string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("auth: generate salt: %w", err)
}
hash := pbkdf2([]byte(password), salt, pbkdf2Iterations, keyLen)
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s",
pbkdf2Iterations,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
// VerifyPassword checks a plaintext password against an encoded hash
// produced by HashPassword, in constant time.
func VerifyPassword(password, encoded string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
iterations, err := strconv.Atoi(parts[1])
if err != nil || iterations <= 0 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
got := pbkdf2([]byte(password), salt, iterations, len(want))
return subtle.ConstantTimeCompare(got, want) == 1
}

View File

@@ -0,0 +1,87 @@
package authsupport
import (
"sync"
"time"
)
// Limiter slows repeated attempts against a single key (an email
// address or a client IP) instead of flatly blocking them: each
// consecutive failure within the window makes the *next* attempt's
// artificial delay longer, capped at maxDelay. That's enough to make
// password-guessing or verification-email-spam scripts painfully slow
// without needing a CAPTCHA or an IP ban list, and it degrades
// gracefully — a genuine user who mistypes their password twice waits a
// second, not minutes.
//
// State is in-memory and per-process, which is fine for enkly's
// single-instance deployment (see docker-compose.yml); it resets on
// restart and doesn't need a database round-trip on the hot path.
type Limiter struct {
mu sync.Mutex
attempts map[string]*attemptState
window time.Duration
baseWait time.Duration
maxWait time.Duration
}
type attemptState struct {
count int
lastSeen time.Time
}
// NewLimiter builds a Limiter. window is how long a run of failures is
// remembered before resetting; baseWait/maxWait bound the artificial
// delay (baseWait * 2^(failures-1), capped at maxWait).
func NewLimiter(window, baseWait, maxWait time.Duration) *Limiter {
return &Limiter{
attempts: make(map[string]*attemptState),
window: window,
baseWait: baseWait,
maxWait: maxWait,
}
}
// Delay returns how long the caller should sleep before processing the
// next attempt for key, based on how many recent failures it has seen.
// It does not itself sleep or record anything — call RecordFailure /
// RecordSuccess to update state.
func (l *Limiter) Delay(key string) time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
st, ok := l.attempts[key]
if !ok || time.Since(st.lastSeen) > l.window {
return 0
}
if st.count <= 0 {
return 0
}
wait := l.baseWait << (st.count - 1) // baseWait * 2^(count-1)
if wait > l.maxWait || wait <= 0 { // <=0 guards against overflow
wait = l.maxWait
}
return wait
}
// RecordFailure increments key's failure streak.
func (l *Limiter) RecordFailure(key string) {
l.mu.Lock()
defer l.mu.Unlock()
st, ok := l.attempts[key]
if !ok || time.Since(st.lastSeen) > l.window {
st = &attemptState{}
l.attempts[key] = st
}
st.count++
st.lastSeen = time.Now()
}
// RecordSuccess clears key's failure streak.
func (l *Limiter) RecordSuccess(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.attempts, key)
}

58
pkg/authsupport/token.go Normal file
View File

@@ -0,0 +1,58 @@
package authsupport
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"math/big"
)
// tokenBytes is the amount of entropy in a raw token before encoding —
// 32 bytes (256 bits) is comfortably enough that guessing is infeasible.
const tokenBytes = 32
// NewToken returns a fresh, URL-safe random token (for password-reset
// links and session cookies) and the hex-encoded SHA-256 hash of it that
// should be persisted in its place. Only the hash ever touches the
// database — the raw token lives solely in the email link or the
// browser's cookie, so a DB dump alone can't be replayed.
func NewToken() (raw string, hash string, err error) {
b := make([]byte, tokenBytes)
if _, err := rand.Read(b); err != nil {
return "", "", fmt.Errorf("auth: generate token: %w", err)
}
raw = base64.RawURLEncoding.EncodeToString(b)
return raw, HashToken(raw), nil
}
// codeSpace is how many distinct 6-digit codes NewCode can produce
// (000000 through 999999).
const codeSpace = 1_000_000
// NewCode returns a fresh random 6-digit numeric code — as a
// zero-padded string, so "007123" round-trips correctly — for emailing
// as an email-verification code, and the hex-encoded SHA-256 hash of it
// that should be persisted in its place. Unlike NewToken's 256 bits of
// entropy, a 6-digit code is deliberately guessable-by-brute-force if
// nothing else stops repeated attempts; callers must pair it with an
// attempt limit and a short expiry (see internal/db.VerifyEmailCode and
// verifyCodeTTL) rather than relying on the code space alone.
func NewCode() (raw string, hash string, err error) {
n, err := rand.Int(rand.Reader, big.NewInt(codeSpace))
if err != nil {
return "", "", fmt.Errorf("auth: generate code: %w", err)
}
raw = fmt.Sprintf("%06d", n.Int64())
return raw, HashToken(raw), nil
}
// HashToken hashes a raw token or code the same way NewToken/NewCode do,
// so a value received back from the client (a reset link's ?token=, a
// session cookie, or a typed verification code) can be looked up by its
// hash.
func HashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,44 @@
package authsupport
import "testing"
func TestNewCodeShape(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 200; i++ {
raw, hash, err := NewCode()
if err != nil {
t.Fatal(err)
}
if len(raw) != 6 {
t.Fatalf("code %q is not 6 digits", raw)
}
for _, c := range raw {
if c < '0' || c > '9' {
t.Fatalf("code %q has a non-digit", raw)
}
}
if hash != HashToken(raw) {
t.Fatalf("hash mismatch for %q", raw)
}
seen[raw] = true
}
// Not a strict randomness test, just a sanity check that we're not
// somehow returning the same code every time.
if len(seen) < 190 {
t.Fatalf("suspiciously few distinct codes out of 200 draws: %d", len(seen))
}
}
func TestNewCodeZeroPadded(t *testing.T) {
// %06d must preserve leading zeros (e.g. a draw of 42 -> "000042"),
// or VerifyEmailCode would compare against a mis-shapen string.
for i := 0; i < 2000; i++ {
raw, _, err := NewCode()
if err != nil {
t.Fatal(err)
}
if len(raw) != 6 {
t.Fatalf("code %q is not zero-padded to 6 digits", raw)
}
}
}

175
pkg/effort/effort.go Normal file
View File

@@ -0,0 +1,175 @@
// Package effort defines enkly's manual reasoning-depth control: five
// named levels (Low, Medium, High, Extra, Max) a caller can pick to
// arbitrate between speed and precision for one agent turn, modeled on
// Claude's effort parameter. It is deliberately a small, dependency-free
// package — just a Level type plus the static Profile each one maps to
// — so both the agent service (which actually runs a turn at a given
// level) and any future transport (gRPC field, HTTP query param, a
// gateway settings dropdown) can depend on it without pulling in
// agentrun or vllmkit.
//
// vLLM/Qwen has no first-class notion of "effort" the way the
// Anthropic API does (a token budget plus a reasoning-depth knob) — the
// only dial it exposes per request is vllmkit.Client's think string
// ("" / "true" / "false" / "low" / "medium" / "high", all of which
// collapse to a single on/off enable_thinking toggle on the wire — see
// vllmkit.Client.Think's doc comment) plus the ordinary generation
// options (max_tokens, temperature, top_p). Profile is this package's
// honest mapping of Claude's five-level vocabulary onto that smaller
// vocabulary: Think picks the per-round reasoning-effort floor (see
// agentrun.EffortThinkPolicy), MaxRounds caps how many tool-call
// round-trips a turn is allowed before Low has to converge fast and Max
// can keep investigating, and NumPredictCap bounds a single reply's
// length so Low genuinely costs less and Max isn't truncated mid-answer.
package effort
import (
"fmt"
"strings"
)
// Level is one of five named reasoning-depth settings, ordered from
// fastest/cheapest to deepest/most thorough.
type Level string
const (
Low Level = "low"
Medium Level = "medium"
High Level = "high"
Extra Level = "extra"
Max Level = "max"
)
// Default is used whenever no level was explicitly selected — the same
// balance the agent struck before effort control existed.
const Default = Medium
// All lists every level in ascending order of depth, for validation
// messages and a future settings-UI dropdown.
var All = []Level{Low, Medium, High, Extra, Max}
// Parse normalizes free-form text (a slash-directive argument, a query
// param, a gRPC field once wired) into a Level. Case/space-insensitive;
// a handful of obvious synonyms are accepted so "quick"/"fast" from the
// fast-mode vocabulary and "deep"/"thorough" from everyday language
// both resolve sensibly instead of erroring on a near-miss.
func Parse(s string) (Level, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "":
return Default, nil
case "low", "fast", "quick", "speed":
return Low, nil
case "medium", "default", "normal", "balanced":
return Medium, nil
case "high", "deep", "careful":
return High, nil
case "extra", "extra-high", "extra_high", "thorough":
return Extra, nil
case "max", "maximum", "deepest", "ultra":
return Max, nil
default:
return "", fmt.Errorf("effort: unknown level %q (want one of low, medium, high, extra, max)", s)
}
}
// Valid reports whether l is one of the five defined levels — distinct
// from Parse accepting synonyms: Valid is for checking an already
// canonical value (e.g. one stored verbatim), Parse is for user input.
func (l Level) Valid() bool {
switch l {
case Low, Medium, High, Extra, Max:
return true
default:
return false
}
}
// Profile is the concrete tuning one Level resolves to. Every field has
// a conservative, locally-reasonable value — there's no remote
// "effort=max" service to defer to the way the Anthropic API has, so
// these numbers are enkly's own calibration for enkly's vLLM
// deployment, meant to be adjusted (see cmd/server/main.go's
// ENKLY_EFFORT_* overrides) rather than treated as gospel.
type Profile struct {
// Level this profile was resolved from, for logging.
Level Level
// Think is the thinking-effort floor passed to the chat model on
// every round of the turn (see vllmkit.Client's Think field). A
// per-round policy (agentrun.EffortThinkPolicy) may still ask for
// more on round 0 or less on a retry — this is the floor beneath
// which it won't go, not an override of every round alike.
Think string
// MaxRounds caps tool-call round-trips for a turn run at this
// level. Low converges fast on purpose; Max gets the same ceiling
// dispatch_workflow's own sub-runs use, so a Max-effort turn can
// still delegate broad work instead of exhausting its own budget
// investigating serially.
MaxRounds int
// NumPredictCap bounds a single model reply's length in tokens (0
// = no cap beyond the client's own configured default). Only Low
// sets one — the other levels trust the model to stop when it's
// done rather than truncating a legitimately long answer.
NumPredictCap int
// ContextBudgetChars overrides agentrun's default context-budget
// threshold (see agentrun.Agent.ContextBudgetChars) for a turn run
// at this level — Max keeps more tool-result history verbatim
// before compacting, trading context budget for fewer "re-call the
// tool if you need it again" gaps on a long investigation.
ContextBudgetChars int
// AdaptiveThinking, when true, lets the per-round policy raise
// effort above Think on a round that looks like it needs it (an
// error result, a round deep into a long turn) instead of holding
// every round at exactly Think — see agentrun.AdaptiveThinkPolicy.
// Low and Medium leave it off (predictable cost); High and above
// turn it on (predictable quality).
AdaptiveThinking bool
// MaxParallelSubagents is the ceiling dispatch_workflow enforces
// for a single dispatch_workflow call made during a turn run at
// this level — see toolexec's workflow orchestrator. Low effort
// still allows some parallel fan-out (a migration-shaped request
// at Low effort is still a migration), just a smaller one.
MaxParallelSubagents int
}
// defaultContextBudgetChars mirrors agentrun's own unexported default
// (contextBudgetChars) so Resolve's Medium profile reproduces the
// agent's pre-effort-control behavior exactly. Keeping one literal
// value here rather than importing agentrun (which would create an
// import cycle: agentrun -> effort -> agentrun) is deliberate — see
// agentrun.Agent.ContextBudgetChars's doc comment for the other half of
// this contract.
const defaultContextBudgetChars = 60_000
// Resolve returns the Profile for l. An invalid l (never produced by
// Parse, but possible from a stored/decoded value) resolves as Medium
// rather than panicking or erroring — effort control degrading to "the
// behavior enkly always had" is always a safe failure mode.
func (l Level) Resolve() Profile {
switch l {
case Low:
return Profile{
Level: Low, Think: "low", MaxRounds: 6, NumPredictCap: 700,
ContextBudgetChars: defaultContextBudgetChars / 2, AdaptiveThinking: false, MaxParallelSubagents: 16,
}
case High:
return Profile{
Level: High, Think: "high", MaxRounds: 16,
ContextBudgetChars: defaultContextBudgetChars * 2, AdaptiveThinking: true, MaxParallelSubagents: 96,
}
case Extra:
return Profile{
Level: Extra, Think: "high", MaxRounds: 24,
ContextBudgetChars: defaultContextBudgetChars * 4, AdaptiveThinking: true, MaxParallelSubagents: 192,
}
case Max:
return Profile{
Level: Max, Think: "high", MaxRounds: 32,
ContextBudgetChars: defaultContextBudgetChars * 8, AdaptiveThinking: true, MaxParallelSubagents: 256,
}
default: // Medium and anything unrecognized
return Profile{
Level: Medium, Think: "", MaxRounds: 12,
ContextBudgetChars: defaultContextBudgetChars, AdaptiveThinking: false, MaxParallelSubagents: 48,
}
}
}

102
pkg/effort/effort_test.go Normal file
View File

@@ -0,0 +1,102 @@
package effort
import "testing"
func TestParse(t *testing.T) {
cases := []struct {
in string
want Level
wantErr bool
}{
{"", Default, false},
{"low", Low, false},
{" LOW ", Low, false},
{"fast", Low, false},
{"medium", Medium, false},
{"default", Medium, false},
{"high", High, false},
{"deep", High, false},
{"extra", Extra, false},
{"thorough", Extra, false},
{"max", Max, false},
{"ultra", Max, false},
{"bogus", "", true},
}
for _, tc := range cases {
got, err := Parse(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("Parse(%q) = %q, nil; want an error", tc.in, got)
}
continue
}
if err != nil {
t.Errorf("Parse(%q) returned unexpected error: %v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("Parse(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestValid(t *testing.T) {
for _, l := range All {
if !l.Valid() {
t.Errorf("%q should be Valid", l)
}
}
if Level("bogus").Valid() {
t.Error(`Level("bogus").Valid() = true, want false`)
}
if Level("").Valid() {
t.Error(`Level("").Valid() = true, want false`)
}
}
func TestResolveOrdering(t *testing.T) {
// Depth should be monotonic across the five levels for every knob
// that's supposed to scale with effort — a regression here (e.g.
// Extra accidentally configured with a smaller budget than High)
// would quietly break the "arbitrate between speed and precision"
// contract the whole feature exists to provide.
levels := []Level{Low, Medium, High, Extra, Max}
var prevRounds, prevBudget, prevParallel int
for i, l := range levels {
p := l.Resolve()
if p.Level != l {
t.Errorf("%s.Resolve().Level = %s, want %s", l, p.Level, l)
}
if i > 0 {
if p.MaxRounds < prevRounds {
t.Errorf("%s.MaxRounds (%d) < %s.MaxRounds (%d); want non-decreasing", l, p.MaxRounds, levels[i-1], prevRounds)
}
if p.ContextBudgetChars < prevBudget {
t.Errorf("%s.ContextBudgetChars (%d) < %s.ContextBudgetChars (%d); want non-decreasing", l, p.ContextBudgetChars, levels[i-1], prevBudget)
}
if p.MaxParallelSubagents < prevParallel {
t.Errorf("%s.MaxParallelSubagents (%d) < %s.MaxParallelSubagents (%d); want non-decreasing", l, p.MaxParallelSubagents, levels[i-1], prevParallel)
}
}
prevRounds, prevBudget, prevParallel = p.MaxRounds, p.ContextBudgetChars, p.MaxParallelSubagents
}
}
func TestResolveInvalidFallsBackToMedium(t *testing.T) {
got := Level("not-a-real-level").Resolve()
want := Medium.Resolve()
if got != want {
t.Errorf("invalid Level.Resolve() = %+v, want Medium's profile %+v", got, want)
}
}
func TestLowHasOutputCapOthersDont(t *testing.T) {
if Low.Resolve().NumPredictCap == 0 {
t.Error("Low should set a NumPredictCap")
}
for _, l := range []Level{Medium, High, Extra, Max} {
if got := l.Resolve().NumPredictCap; got != 0 {
t.Errorf("%s.NumPredictCap = %d, want 0 (uncapped)", l, got)
}
}
}

View File

@@ -0,0 +1,105 @@
// Package entitlements resolves which premium agent capabilities a
// given subscription plan unlocks — today, specifically dynamic
// workflows (dispatch_workflow's parallel multi-sub-agent
// orchestration). Effort control and fast mode are deliberately NOT
// gated here: per the product brief, those two stay available on every
// plan, so there is nothing for this package to decide about them.
//
// Why this is its own small package rather than a field on billing's
// domain.Plan: the agent service never imports billing's domain
// package directly (see ARCHITECTURE.md's layering rules — Agent calls
// Billing only over gRPC, as a saga coordinator, and billing's
// CheckAndIncrementUsageResponse already hands back the plan as a
// plain string, PlanName, specifically so a caller doesn't need
// billing's own types to make a decision from it). Keeping the
// plan-name -> capability mapping here, keyed by that same string,
// lets the agent service gate a feature without a new cross-service
// dependency, and lets ops retarget which plan names unlock dynamic
// workflows via one env var (see DynamicWorkflowPlansEnv) with no code
// change — which matters because, as of this package's introduction,
// billing's own plan catalog (services/billing/internal/domain/
// subscription.go) only names four tiers (free/basic/pro/premium), not
// the Enterprise/Team/Max vocabulary dynamic workflows are meant to
// ship on. "premium" — today's top tier — is the default stand-in for
// all three until billing's catalog is extended with real Team/
// Enterprise/Max entries (a Catalog append plus a migration touching
// the `plan` CHECK constraint — see that file's own doc comment — not
// a change this package needs to know about).
package entitlements
import (
"strings"
)
// DynamicWorkflowPlansEnv is the environment variable
// cmd/server/main.go reads to build a Set's plan allowlist — a
// comma-separated list of plan names (matched case-insensitively
// against billing's CheckAndIncrementUsageResponse.PlanName), e.g.
// "premium,team,enterprise,max". Unset or empty means "use
// DefaultDynamicWorkflowPlans".
const DynamicWorkflowPlansEnv = "ENKLY_DYNAMIC_WORKFLOW_PLANS"
// DefaultDynamicWorkflowPlans is used when DynamicWorkflowPlansEnv is
// unset — "premium" is billing's existing top tier; see the package
// doc comment for why that's today's honest stand-in for
// Enterprise/Team/Max.
var DefaultDynamicWorkflowPlans = []string{"premium"}
// Catalog is the resolved plan allowlist — built once at startup (see
// ParsePlanList) and consulted per request via Resolve. Kept as a
// small value type rather than a package-level global so a test (or a
// future per-tenant override) can construct its own without mutating
// shared state.
type Catalog struct {
dynamicWorkflowPlans map[string]bool
}
// ParsePlanList builds a Catalog from a comma-separated plan list (see
// DynamicWorkflowPlansEnv's format); an empty/whitespace-only raw
// falls back to DefaultDynamicWorkflowPlans.
func ParsePlanList(raw string) Catalog {
fields := strings.Split(raw, ",")
names := make([]string, 0, len(fields))
for _, f := range fields {
if f = strings.ToLower(strings.TrimSpace(f)); f != "" {
names = append(names, f)
}
}
if len(names) == 0 {
names = DefaultDynamicWorkflowPlans
}
set := make(map[string]bool, len(names))
for _, n := range names {
set[n] = true
}
return Catalog{dynamicWorkflowPlans: set}
}
// Set is one user's resolved capabilities for one turn — what
// application.Service computes once (from the plan name billing's
// CheckAndIncrementUsage already returned) and attaches to the run
// context via toolexec.WithEntitlements, for dispatch_workflow to read
// back without any gRPC call of its own.
type Set struct {
// DynamicWorkflows reports whether dispatch_workflow is usable this
// turn.
DynamicWorkflows bool
// PlanName is the plan the decision was made from, carried along
// purely so a denial message can name it (e.g. "not available on
// the Free plan") without a second lookup.
PlanName string
}
// Resolve decides a Set for planName against c's allowlist. An unknown
// or empty planName (a billing lookup that failed open, or a caller
// that never set one) resolves as not entitled — a gated feature
// failing closed on missing information is the safe default, unlike
// the daily-message-quota check in application.Service.StartTurn,
// which deliberately fails *open* because getting that wrong costs
// someone a chat message, not an unintended unlock of a paid feature.
func (c Catalog) Resolve(planName string) Set {
return Set{
DynamicWorkflows: c.dynamicWorkflowPlans[strings.ToLower(strings.TrimSpace(planName))],
PlanName: planName,
}
}

79
pkg/envkit/dotenv.go Normal file
View File

@@ -0,0 +1,79 @@
// Package config loads ENKLY_* configuration from a ".env" file into the
// process environment, so `os.Getenv` calls throughout the app (see
// getenv() in cmd/server/main.go) pick up values from ".env" the same
// way they'd pick up real environment variables — without adding a
// third-party dependency like godotenv (this module intentionally keeps
// go.mod to lib/pq + gomarkdown; see the comment on that file).
package envkit
import (
"bufio"
"fmt"
"os"
"strings"
)
// LoadDotEnv reads simple KEY=VALUE lines from path and calls
// os.Setenv for each one whose key isn't already set in the real
// environment — actual environment variables always win, so
// `ENKLY_SMTP_PASSWORD=x make run` still overrides whatever ".env"
// says. Missing files are not an error: ".env" is optional (e.g. in
// docker-compose.yml, config comes from `environment:` instead).
//
// Supported syntax, deliberately small:
//
// KEY=value
// KEY="value with spaces, or a # that isn't a comment"
// KEY='value'
// # full-line comments and blank lines are ignored
// export KEY=value (a leading "export " is stripped, for .env files
// written to be `source`-able in a shell too)
//
// No variable interpolation, no multi-line values.
func LoadDotEnv(path string) error {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("config: open %s: %w", path, err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
lineNo := 0
for scanner.Scan() {
lineNo++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
key, value, ok := strings.Cut(line, "=")
if !ok {
return fmt.Errorf("config: %s:%d: expected KEY=VALUE, got %q", path, lineNo, line)
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" {
return fmt.Errorf("config: %s:%d: empty key", path, lineNo)
}
// Strip a single matching pair of surrounding quotes, if present.
if len(value) >= 2 {
first, last := value[0], value[len(value)-1]
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
value = value[1 : len(value)-1]
}
}
if _, alreadySet := os.LookupEnv(key); alreadySet {
continue // real environment variables take precedence
}
if err := os.Setenv(key, value); err != nil {
return fmt.Errorf("config: set %s: %w", key, err)
}
}
return scanner.Err()
}

60
pkg/envkit/getenv.go Normal file
View File

@@ -0,0 +1,60 @@
package envkit
import (
"os"
"strconv"
)
// String returns the environment variable key, or fallback if unset/empty.
// Same semantics as the getenv() helper every cmd/server/main.go in the
// monolith defined locally — hoisted here once since every microservice
// now needs it.
func String(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// Int parses the environment variable key as an integer, or returns
// fallback if unset or unparseable.
func Int(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
// Float parses the environment variable key as a float64, or returns
// fallback if unset or unparseable.
func Float(key string, fallback float64) float64 {
v := os.Getenv(key)
if v == "" {
return fallback
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return fallback
}
return f
}
// Bool parses the environment variable key as a bool ("true"/"1"/"yes",
// case-insensitive, or their opposites), or returns fallback if unset
// or unparseable.
func Bool(key string, fallback bool) bool {
v := os.Getenv(key)
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}

159
pkg/eventbus/bus.go Normal file
View File

@@ -0,0 +1,159 @@
package eventbus
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
// Bus is a small, reconnect-free wrapper over one amqp091 connection and
// channel. It declares ExchangeName as a durable topic exchange on
// connect so publishers and consumers never race over who creates it
// first.
//
// Deliberately minimal: no automatic reconnect/retry loop, no publisher
// confirms, no dead-letter exchange. Those are real production
// concerns, but adding them here would be infrastructure plumbing
// unrelated to the DDD restructuring this package exists to
// demonstrate — see the "Operational hardening" section of
// ARCHITECTURE.md for what a production rollout should add before this
// goes live.
type Bus struct {
conn *amqp.Connection
channel *amqp.Channel
logger *slog.Logger
}
// Connect dials url (an amqp:// URI) and declares the shared topology.
func Connect(url string, logger *slog.Logger) (*Bus, error) {
conn, err := amqp.Dial(url)
if err != nil {
return nil, fmt.Errorf("dial rabbitmq: %w", err)
}
ch, err := conn.Channel()
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("open channel: %w", err)
}
if err := ch.ExchangeDeclare(ExchangeName, "topic", true, false, false, false, nil); err != nil {
_ = ch.Close()
_ = conn.Close()
return nil, fmt.Errorf("declare exchange: %w", err)
}
return &Bus{conn: conn, channel: ch, logger: logger}, nil
}
// Close releases the underlying channel and connection.
func (b *Bus) Close() error {
if b == nil {
return nil
}
if err := b.channel.Close(); err != nil {
_ = b.conn.Close()
return err
}
return b.conn.Close()
}
// Envelope is the JSON structure every event is published as. OccurredAt
// and RoutingKey are set by Publish; Payload is whatever event-specific
// struct the caller passes (see pkg/eventbus/events).
type Envelope struct {
RoutingKey string `json:"routing_key"`
OccurredAt time.Time `json:"occurred_at"`
Payload json.RawMessage `json:"payload"`
}
// Publish marshals payload and sends it to ExchangeName with the given
// routing key, as a persistent message (survives a broker restart).
// Fire-and-forget: the caller's own request/response flow (over gRPC)
// must never block on this succeeding — a publish failure is logged,
// not propagated, exactly like the monolith's own "log, don't fail the
// request" stance on e.g. a cleanup-loop error.
func (b *Bus) Publish(ctx context.Context, routingKey string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal event payload: %w", err)
}
env := Envelope{RoutingKey: routingKey, OccurredAt: time.Now().UTC(), Payload: body}
envBody, err := json.Marshal(env)
if err != nil {
return fmt.Errorf("marshal envelope: %w", err)
}
return b.channel.PublishWithContext(ctx, ExchangeName, routingKey, false, false, amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Timestamp: time.Now(),
Body: envBody,
})
}
// PublishBestEffort calls Publish and logs (rather than returns) any
// error — the shape every call site in this codebase actually wants,
// since a notification being late or lost must never fail the
// synchronous gRPC call that triggered it.
func (b *Bus) PublishBestEffort(ctx context.Context, routingKey string, payload any) {
if err := b.Publish(ctx, routingKey, payload); err != nil {
b.logger.Error("publish event", "routing_key", routingKey, "err", err)
}
}
// Handler processes one decoded event. Returning an error nacks the
// delivery with requeue=true; a consumer that can't make progress
// should log and return nil instead of spinning forever on one poison
// message (the notification service does exactly this — see
// services/notification/internal/infrastructure/eventing).
type Handler func(ctx context.Context, env Envelope) error
// Consume declares binding.Queue durable, binds it to every routing key
// in binding.RoutingKeys, and runs handler for each delivery until ctx
// is cancelled. Blocks the calling goroutine.
func (b *Bus) Consume(ctx context.Context, binding QueueBinding, handler Handler) error {
q, err := b.channel.QueueDeclare(binding.Queue, true, false, false, false, nil)
if err != nil {
return fmt.Errorf("declare queue %s: %w", binding.Queue, err)
}
for _, key := range binding.RoutingKeys {
if err := b.channel.QueueBind(q.Name, key, ExchangeName, false, nil); err != nil {
return fmt.Errorf("bind queue %s to %s: %w", binding.Queue, key, err)
}
}
// Process one message at a time per consumer goroutine — the
// notification service's send-an-email handler is I/O bound on
// SMTP, not CPU bound, so this keeps ordering simple without
// needing a worker pool for the volumes this is sized for.
if err := b.channel.Qos(1, 0, false); err != nil {
return fmt.Errorf("set qos: %w", err)
}
deliveries, err := b.channel.Consume(q.Name, "", false, false, false, false, nil)
if err != nil {
return fmt.Errorf("consume %s: %w", q.Name, err)
}
for {
select {
case <-ctx.Done():
return nil
case d, ok := <-deliveries:
if !ok {
return fmt.Errorf("delivery channel closed for queue %s", q.Name)
}
var env Envelope
if err := json.Unmarshal(d.Body, &env); err != nil {
b.logger.Error("decode event envelope", "queue", q.Name, "err", err)
_ = d.Nack(false, false) // malformed — don't requeue
continue
}
if err := handler(ctx, env); err != nil {
b.logger.Error("handle event", "queue", q.Name, "routing_key", env.RoutingKey, "err", err)
_ = d.Nack(false, true)
continue
}
_ = d.Ack(false)
}
}
}

View File

@@ -0,0 +1,14 @@
package events
// SubscriptionChanged is published by billing whenever applyStripeEvent
// (now billing's internal/application use case of the same name)
// commits a plan/status change from a Stripe webhook. No consumer in
// this port of the monolith needs it synchronously — it's published so
// a future consumer (e.g. a usage-analytics or dunning-email service)
// can react without billing needing to know about it.
type SubscriptionChanged struct {
UserID int64 `json:"user_id"`
Plan string `json:"plan"`
Status string `json:"status"`
EventType string `json:"stripe_event_type"` // e.g. "customer.subscription.updated"
}

View File

@@ -0,0 +1,12 @@
package events
// MessageCreated is published by conversation on every db.AddMessage
// equivalent. Unused by any consumer in this port — present so the
// pattern (every aggregate commit gets an event, even before a consumer
// exists for it) is consistent across contexts; see
// pkg/eventbus.RoutingKeyMessageCreated.
type MessageCreated struct {
ConversationID int64 `json:"conversation_id"`
MessageID int64 `json:"message_id"`
Role string `json:"role"`
}

View File

@@ -0,0 +1,52 @@
// Package events holds the JSON payload shape for every event listed in
// pkg/eventbus's routing-key constants. Each type is versioned
// implicitly by its routing key (a breaking change gets a new key, e.g.
// "iam.user.registered.v2") rather than by a field in the struct, to
// keep consumers' decoding code simple.
package events
import "time"
// VerificationCodeIssued is published by iam whenever a 6-digit email
// verification code is minted — at signup (Handlers.Signup) and on
// resend (Handlers.ResendVerification). The notification service is its
// only consumer today (pkg/eventbus.RoutingKeyVerificationCodeIssued).
//
// Carrying the raw code on the bus (rather than just a code ID) is a
// deliberate, documented trade-off: it lets the email's content stay
// exactly what the monolith sent without notification needing its own
// read access to iam's verification_codes table (which would violate
// "each service owns its own tables"). This is safe only because
// RabbitMQ sits on the private service network, never reachable from
// outside the cluster — see ARCHITECTURE.md's "Trust boundary of the
// event bus" note.
type VerificationCodeIssued struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
Code string `json:"code"`
ExpiresAt time.Time `json:"expires_at"`
}
// PasswordResetRequested is published by iam from Handlers.ForgotPassword.
type PasswordResetRequested struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
ResetLink string `json:"reset_link"` // already absolute (BaseURL + path + token), matching h.absURL
ExpiresAt time.Time `json:"expires_at"`
}
// UserRegistered is published once a new account row is committed
// (before email verification). Reserved for consumers that care about
// the account existing at all rather than about delivering mail — e.g.
// an analytics or growth service added later.
type UserRegistered struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
// UserEmailVerified is published when VerifyEmailCode succeeds.
type UserEmailVerified struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
}

63
pkg/eventbus/topology.go Normal file
View File

@@ -0,0 +1,63 @@
// Package eventbus is enkly's thin wrapper around RabbitMQ (via
// github.com/rabbitmq/amqp091-go) for every *asynchronous*,
// fire-and-forget cross-context notification — the counterpart to the
// synchronous, request/response calls services make over gRPC (see
// api/proto). Per-service request/response traffic (e.g. the agent
// service asking billing "is this user over quota?") always uses gRPC;
// RabbitMQ is reserved for events a publisher doesn't need an answer to,
// mirroring the rule stated in the task: "gRPC for synchronous
// communication, RabbitMQ for asynchronous communication."
//
// Topology: one durable topic exchange, "enkly.events". Each event is
// published with a routing key of the form "<context>.<aggregate>.
// <event>" (e.g. "iam.user.registered"); a consumer binds a durable
// queue to the routing keys it cares about. Using a topic exchange
// rather than one queue per consumer lets a new consumer be added later
// (e.g. an analytics service) without touching the publisher.
package eventbus
// ExchangeName is the single durable topic exchange every service
// publishes domain events to.
const ExchangeName = "enkly.events"
// Routing keys. Each constant is "<owning context>.<aggregate>.<event,
// past tense>" — only the owning bounded context ever publishes to its
// own keys (see each service's internal/infrastructure/eventing
// package), but any service may subscribe.
const (
// Published by iam (services/iam) — see internal/handlers/auth.go's
// sendVerificationEmail/sendResetEmail, now decoupled from the
// synchronous request path.
RoutingKeyVerificationCodeIssued = "iam.user.verification_code_issued"
RoutingKeyPasswordResetRequested = "iam.user.password_reset_requested"
RoutingKeyUserRegistered = "iam.user.registered"
RoutingKeyUserEmailVerified = "iam.user.email_verified"
// Published by billing (services/billing).
RoutingKeySubscriptionChanged = "billing.subscription.changed"
// Published by conversation (services/conversation) — not consumed
// by anything in this port of the monolith, but kept so a future
// consumer (search indexing, analytics, audit) has something to
// bind to without a producer-side change.
RoutingKeyMessageCreated = "conversation.message.created"
)
// QueueBinding names the durable queue a consumer should declare and
// the routing key(s) it should bind, so every consumer's topology is
// declared in one place next to the keys it reads.
type QueueBinding struct {
Queue string
RoutingKeys []string
}
// NotificationQueueBindings is the notification service's subscription
// (services/notification/internal/infrastructure/eventing) — every
// event that results in an outbound email.
var NotificationQueueBindings = QueueBinding{
Queue: "enkly.notification.verification_and_reset",
RoutingKeys: []string{
RoutingKeyVerificationCodeIssued,
RoutingKeyPasswordResetRequested,
},
}

24
pkg/grpcerr/grpcerr.go Normal file
View File

@@ -0,0 +1,24 @@
// Package grpcerr has the handful of status-coded error constructors
// every service's interfaces/grpc package uses, so "not found" maps to
// codes.NotFound the same way in all eight of them instead of each
// service inventing its own convention.
package grpcerr
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func NotFound(what string) error { return status.Errorf(codes.NotFound, "%s not found", what) }
func InvalidArgument(msg string) error { return status.Error(codes.InvalidArgument, msg) }
func FailedPrecondition(msg string) error { return status.Error(codes.FailedPrecondition, msg) }
func ResourceExhausted(msg string) error { return status.Error(codes.ResourceExhausted, msg) }
func Internal(err error) error { return status.Error(codes.Internal, err.Error()) }
func Unauthenticated(msg string) error { return status.Error(codes.Unauthenticated, msg) }
// Unavailable maps a dependency that is reachable-in-principle but not
// reachable right now (a vLLM deployment stuck Pending/restarting, a
// downstream service mid-rollout) onto codes.Unavailable instead of
// codes.Internal — gRPC clients and well-behaved gateways treat
// Unavailable as safe-to-retry, which an opaque Internal never is.
func Unavailable(err error) error { return status.Error(codes.Unavailable, err.Error()) }

26
pkg/i18n/embed.go Normal file
View File

@@ -0,0 +1,26 @@
package i18n
import (
"embed"
"fmt"
)
//go:embed locales/*.json
var localeFiles embed.FS
// catalogFS is the raw JSON for each locale in Supported, read once at
// package init time (see i18n.go's init). Keeping the embed.FS read
// here, separate from the JSON-decoding in init, means adding a
// locale is purely data: list it in Supported above, drop its file
// under locales/, and this loop picks it up — no new Go code.
var catalogFS = func() map[Locale][]byte {
out := make(map[Locale][]byte, len(Supported))
for _, info := range Supported {
data, err := localeFiles.ReadFile(fmt.Sprintf("locales/%s.json", info.Code))
if err != nil {
panic(fmt.Sprintf("i18n: locale %q listed in Supported but locales/%s.json is missing: %v", info.Code, info.Code, err))
}
out[info.Code] = data
}
return out
}()

166
pkg/i18n/i18n.go Normal file
View File

@@ -0,0 +1,166 @@
// Package i18n is enkly's shared translation catalog: the single
// source of truth, for every service, of which UI languages exist and
// what their strings are. It is deliberately tiny and dependency-free
// (encoding/json + embed, nothing else) so both the gateway (which
// renders the HTML chrome a visitor reads) and the agent service
// (which needs the same locale codes and display names to tell the
// model which language the visitor has chosen — see
// agentrun.WithPreferredLanguage) can import it without pulling either
// service into the other's build.
//
// This is UI-chrome localization — buttons, labels, system-generated
// notices ("you've reached today's message limit") — not a substitute
// for the agent's own language behavior: the model already detects
// and mirrors whatever language a user types in conversation (see
// agentrun.SystemPrompt's "## Language" section). The two compose: a
// visitor who has never touched the language switcher still gets
// replies in their own language because the model notices; a visitor
// who *has* set a UI language gets the chrome in that language too,
// and the agent treats it as their standing preference (see
// agentrun.WithPreferredLanguage's doc comment for exactly how the
// two signals are reconciled when they disagree).
package i18n
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"sync"
)
// Locale is a BCP-47-ish language tag — in practice just the short
// codes listed in Supported below ("en", "fr", …). It's a defined
// type rather than a bare string so a caller can't accidentally pass
// a translation key where a locale was expected, or vice versa.
type Locale string
// Default is used whenever no locale can be determined at all (no
// cookie, no Accept-Language header, no account preference) and as
// the fallback catalog for any key missing from a more specific
// locale's translation file.
const Default Locale = "en"
// Info is one supported locale's code and its own display name (in
// that language, not in the viewer's — "Français", not "French" —
// which is how every language picker from a browser's own settings to
// Claude Code's `/language` command does it).
type Info struct {
Code Locale
Name string
}
// Supported lists every locale enkly ships a catalog for, in the
// order a language switcher should present them. Adding a language is
// a two-line change: a new entry here, naming the file added under
// locales/, and the entry in catalogs below the embed directive.
//
// enkly's first deployments are French West African (see the FCFA
// pricing in the billing service), so French ships complete; the rest
// cover enough of the chrome (nav, composer, auth, errors) to be
// usable, falling back to English for anything not yet translated —
// see T's doc comment. Extending any of them to full coverage is a
// matter of filling in locales/<code>.json, not touching any Go code.
var Supported = []Info{
{Code: "en", Name: "English"},
{Code: "fr", Name: "Français"},
{Code: "es", Name: "Español"},
{Code: "de", Name: "Deutsch"},
{Code: "pt", Name: "Português"},
}
// IsSupported reports whether code names a locale enkly ships a
// catalog for.
func IsSupported(code string) bool {
for _, info := range Supported {
if string(info.Code) == code {
return true
}
}
return false
}
// ParseLocale returns code as a Locale if it's one enkly supports,
// else Default. Use this anywhere an untrusted string (a cookie
// value, a query parameter, one tag out of an Accept-Language header)
// needs to become a Locale — it never returns an unsupported value.
func ParseLocale(code string) Locale {
if IsSupported(code) {
return Locale(code)
}
return Default
}
// NameOf returns a locale's own display name ("Français" for "fr"),
// or its bare code if it isn't one of Supported (shouldn't happen for
// a Locale that came from ParseLocale, but T and callers that build a
// prompt string from a Locale value want something printable either
// way).
func NameOf(l Locale) string {
for _, info := range Supported {
if info.Code == l {
return info.Name
}
}
return string(l)
}
var (
mu sync.RWMutex
missLog = map[string]bool{} // "locale/key" already warned about, so a hot template doesn't spam the log
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
catalogs map[Locale]map[string]string
)
func init() {
catalogs = make(map[Locale]map[string]string, len(catalogFS))
for locale, raw := range catalogFS {
var strings map[string]string
if err := json.Unmarshal(raw, &strings); err != nil {
// A malformed embedded catalog is a build-time mistake, not
// a runtime condition any caller can recover from — fail
// loudly at process start rather than silently serving
// blank strings in production.
panic(fmt.Sprintf("i18n: locale %q: invalid catalog JSON: %v", locale, err))
}
catalogs[locale] = strings
}
}
// T looks up key in locale's catalog and, if args is non-empty, runs
// the result through fmt.Sprintf (so a catalog entry can contain %s /
// %d verbs the same way Go source would). A key missing from locale
// falls back to the Default catalog, and a key missing from Default
// too falls back to the key itself — so a template that references a
// not-yet-translated string degrades to showing its own key (an
// unmistakable "translate me" placeholder during development) instead
// of a blank space or a panic in production. Each distinct miss is
// logged once per process, not once per request.
func T(locale Locale, key string, args ...any) string {
mu.RLock()
s, ok := catalogs[locale][key]
if !ok {
s, ok = catalogs[Default][key]
}
mu.RUnlock()
if !ok {
warnMissing(locale, key)
s = key
}
if len(args) == 0 {
return s
}
return fmt.Sprintf(s, args...)
}
func warnMissing(locale Locale, key string) {
id := string(locale) + "/" + key
mu.Lock()
defer mu.Unlock()
if missLog[id] {
return
}
missLog[id] = true
logger.Warn("i18n: missing translation key", "locale", locale, "key", key)
}

54
pkg/i18n/i18n_test.go Normal file
View File

@@ -0,0 +1,54 @@
package i18n
import "testing"
func TestSupportedLocalesLoad(t *testing.T) {
for _, info := range Supported {
cat, ok := catalogs[info.Code]
if !ok || len(cat) == 0 {
t.Fatalf("locale %q: catalog missing or empty", info.Code)
}
}
}
func TestEnglishHasEveryKeyUsedElsewhere(t *testing.T) {
// English is the fallback catalog (see T's doc comment), so every
// key any other locale defines must also exist in English — a
// locale-only key would be silently unreachable from the fallback
// path and is almost certainly a typo.
en := catalogs[Default]
for _, info := range Supported {
if info.Code == Default {
continue
}
for key := range catalogs[info.Code] {
if _, ok := en[key]; !ok {
t.Errorf("locale %q defines key %q that is missing from %q (the fallback catalog)", info.Code, key, Default)
}
}
}
}
func TestTFallsBackToEnglishThenToKey(t *testing.T) {
if got := T("fr", "nav.new_chat"); got != "Nouvelle discussion" {
t.Errorf("fr nav.new_chat = %q", got)
}
// "nav.delete" only exists in a handful of locales' full set; pick
// a locale/key combination guaranteed to be English-only to prove
// the fallback path, without hard-coding which keys those are.
if got := T("es", "billing.quota_exceeded", "Pro", 200); got == "billing.quota_exceeded" {
t.Errorf("expected fallback to the English catalog, got the raw key back")
}
if got := T(Default, "this.key.does.not.exist"); got != "this.key.does.not.exist" {
t.Errorf("unknown key should fall back to itself, got %q", got)
}
}
func TestParseLocale(t *testing.T) {
if got := ParseLocale("fr"); got != "fr" {
t.Errorf("ParseLocale(fr) = %q", got)
}
if got := ParseLocale("xx-not-a-locale"); got != Default {
t.Errorf("ParseLocale(unsupported) = %q, want Default", got)
}
}

74
pkg/i18n/locales/de.json Normal file
View File

@@ -0,0 +1,74 @@
{
"nav.new_chat": "Neuer Chat",
"nav.conversations": "Unterhaltungen",
"nav.no_conversations": "Noch keine Unterhaltungen.",
"nav.plan_suffix": "Tarif",
"nav.manage": "Verwalten",
"nav.log_out": "Abmelden",
"nav.theme_light": "Hell",
"nav.theme_dark": "Dunkel",
"nav.theme_auto": "Automatisch",
"nav.language": "Sprache",
"nav.search_placeholder": "Chats, Dateien, Aktionen durchsuchen…",
"nav.agent_ready": "Agent bereit",
"nav.rename_save": "Namen speichern",
"nav.delete": "Löschen",
"nav.close_sidebar": "Seitenleiste schließen",
"nav.open_sidebar": "Seitenleiste öffnen",
"hero.title": "Was bauen wir heute?",
"hero.subtitle": "enkly arbeitet direkt in deinem Arbeitsbereich: liest, durchsucht, bearbeitet und führt Code aus und zeigt jeden Schritt.",
"hero.start_with": "Beginne mit",
"hero.tools_heading": "Was enkly nutzen kann",
"hero.explore_heading": "Mehr entdecken",
"starter.tour_title": "Arbeitsbereich erkunden",
"starter.tour_desc": "Ordner, Dienste und Einstiegspunkte kartieren.",
"starter.trace_title": "Eine Funktion verfolgen",
"starter.trace_desc": "Eine Anfrage durch den Code verfolgen.",
"starter.fix_title": "Einen Fehler beheben",
"starter.fix_desc": "Beschreibe ihn, enkly findet und behebt ihn.",
"starter.tests_title": "Tests ausführen",
"starter.tests_desc": "Testsuite ausführen und Fehlschläge erklären.",
"tool.read": "Dateien lesen",
"tool.write": "Schreiben und bearbeiten",
"tool.search": "Codebasis durchsuchen",
"tool.run": "Erlaubte Befehle ausführen",
"tool.subagents": "Subagenten",
"explore.tests": "Tests schreiben für…",
"explore.review": "Code überprüfen",
"explore.explain": "Eine Datei erklären",
"explore.document": "Ein Dokument entwerfen",
"explore.refactor": "Refaktorisieren",
"composer.placeholder": "Bitte enkly, etwas zu bauen, zu reparieren oder zu erklären…",
"composer.send": "Senden",
"composer.attach": "Dateien oder Fotos hinzufügen",
"composer.hint_send": "senden",
"composer.hint_newline": "neue Zeile",
"composer.hint_drop": "Dateien ablegen, Screenshots einfügen oder ein .zip-Projekt hinzufügen",
"auth.login_title": "Anmelden",
"auth.login_button": "Anmelden",
"auth.email_label": "E-Mail",
"auth.password_label": "Passwort",
"auth.confirm_password_label": "Passwort bestätigen",
"auth.no_account": "Noch kein Konto?",
"auth.signup_link": "Registrieren",
"auth.signup_title": "Konto erstellen",
"auth.signup_button": "Registrieren",
"auth.have_account": "Bereits ein Konto?",
"auth.login_link": "Anmelden",
"auth.forgot_link": "Passwort vergessen?",
"auth.verify_title": "E-Mail bestätigen",
"auth.verify_code_label": "6-stelliger Code",
"auth.verify_button": "Bestätigen",
"auth.resend_link": "Code erneut senden",
"auth.forgot_title": "Passwort zurücksetzen",
"auth.forgot_button": "Link zum Zurücksetzen senden",
"auth.back_to_login": "Zurück zur Anmeldung",
"auth.reset_title": "Neues Passwort wählen",
"auth.reset_button": "Passwort zurücksetzen"
}

104
pkg/i18n/locales/en.json Normal file
View File

@@ -0,0 +1,104 @@
{
"nav.new_chat": "New chat",
"nav.conversations": "Conversations",
"nav.no_conversations": "No conversations yet.",
"nav.plan_suffix": "plan",
"nav.manage": "Manage",
"nav.log_out": "Log out",
"nav.theme_light": "Light",
"nav.theme_dark": "Dark",
"nav.theme_auto": "Auto",
"nav.language": "Language",
"nav.search_placeholder": "Search chats, files, actions…",
"nav.agent_ready": "Agent ready",
"nav.rename_save": "Save name",
"nav.delete": "Delete",
"nav.close_sidebar": "Close sidebar",
"nav.open_sidebar": "Open sidebar",
"hero.title": "What should we build today?",
"hero.subtitle": "enkly works inside your workspace: it reads, searches, edits and runs code, then shows every step.",
"hero.start_with": "Start with",
"hero.tools_heading": "What enkly can use",
"hero.explore_heading": "More to explore",
"starter.tour_title": "Tour this workspace",
"starter.tour_desc": "Map the folders, services and entry points.",
"starter.trace_title": "Trace a feature",
"starter.trace_desc": "Follow a request through the codebase.",
"starter.fix_title": "Fix a bug",
"starter.fix_desc": "Describe it, enkly finds and patches it.",
"starter.tests_title": "Run the tests",
"starter.tests_desc": "Execute the suite and explain what broke.",
"tool.read": "Read files",
"tool.write": "Write and edit",
"tool.search": "Search the codebase",
"tool.run": "Run allowlisted commands",
"tool.subagents": "Sub-agents",
"explore.tests": "Write tests for…",
"explore.review": "Review code",
"explore.explain": "Explain a file",
"explore.document": "Draft a document",
"explore.refactor": "Refactor",
"composer.placeholder": "Ask enkly to build, fix, or explain anything…",
"composer.send": "Send",
"composer.attach": "Add files or photos",
"composer.hint_send": "send",
"composer.hint_newline": "new line",
"composer.hint_drop": "Drop files, paste screenshots or add a .zip project",
"auth.login_title": "Log in",
"auth.login_button": "Log in",
"auth.email_label": "Email",
"auth.password_label": "Password",
"auth.confirm_password_label": "Confirm password",
"auth.no_account": "Don't have an account?",
"auth.signup_link": "Sign up",
"auth.signup_title": "Create your account",
"auth.signup_button": "Sign up",
"auth.have_account": "Already have an account?",
"auth.login_link": "Log in",
"auth.forgot_link": "Forgot your password?",
"auth.verify_title": "Verify your email",
"auth.verify_code_label": "6-digit code",
"auth.verify_button": "Verify",
"auth.resend_link": "Resend code",
"auth.forgot_title": "Reset your password",
"auth.forgot_button": "Send reset link",
"auth.back_to_login": "Back to log in",
"auth.reset_title": "Choose a new password",
"auth.reset_button": "Reset password",
"errors.generic": "Something went wrong. Please try again.",
"errors.invalid_email": "Enter a valid email address.",
"errors.password_min": "Password must be at least %d characters.",
"errors.password_max": "Password must be at most %d characters.",
"errors.password_mismatch": "Passwords don't match.",
"errors.incorrect_credentials": "Incorrect email or password.",
"errors.invalid_code": "Enter the 6-digit code.",
"errors.code_incorrect": "That code is incorrect or has expired.",
"errors.reset_link_invalid": "That reset link is invalid or has expired. Request a new one.",
"errors.csrf": "Invalid or missing security token — reload the page and try again.",
"info.check_inbox": "Check your inbox for a 6-digit verification code.",
"info.mail_not_configured": "Email isn't configured on this server — check the server log for your verification code.",
"info.resend_sent": "If that account needs verifying, a new code is on its way.",
"info.email_verified": "Your email is verified — log in to continue.",
"info.password_reset_done": "Your password has been reset — log in with your new password.",
"info.forgot_sent": "If that address has an account, a reset link is on its way.",
"chat.type_message_first": "Type a message first.",
"chat.message_too_long": "That message is too long (limit %d characters).",
"chat.rate_limited": "Too many requests — slow down a little.",
"chat.conversation_gone": "That conversation no longer exists. Start a new chat.",
"chat.conversation_busy": "enkly is still replying in this conversation — wait for it to finish, or press Stop.",
"chat.too_many_streams": "You have several replies in progress already. Wait for one to finish or press Stop.",
"chat.save_failed": "Couldn't save your message. Please try again.",
"chat.load_failed": "Couldn't load the conversation. Please try again.",
"chat.send_failed": "Something went wrong. Please try again.",
"billing.quota_exceeded": "You've reached the %s plan's limit of %d messages today. Upgrade on the Subscription page for more."
}

74
pkg/i18n/locales/es.json Normal file
View File

@@ -0,0 +1,74 @@
{
"nav.new_chat": "Nueva conversación",
"nav.conversations": "Conversaciones",
"nav.no_conversations": "Aún no hay conversaciones.",
"nav.plan_suffix": "plan",
"nav.manage": "Gestionar",
"nav.log_out": "Cerrar sesión",
"nav.theme_light": "Claro",
"nav.theme_dark": "Oscuro",
"nav.theme_auto": "Auto",
"nav.language": "Idioma",
"nav.search_placeholder": "Buscar chats, archivos, acciones…",
"nav.agent_ready": "Agente listo",
"nav.rename_save": "Guardar nombre",
"nav.delete": "Eliminar",
"nav.close_sidebar": "Cerrar barra lateral",
"nav.open_sidebar": "Abrir barra lateral",
"hero.title": "¿Qué construimos hoy?",
"hero.subtitle": "enkly trabaja dentro de tu espacio de trabajo: lee, busca, edita y ejecuta código, y muestra cada paso.",
"hero.start_with": "Empieza con",
"hero.tools_heading": "Lo que enkly puede usar",
"hero.explore_heading": "Más para explorar",
"starter.tour_title": "Recorrer este espacio de trabajo",
"starter.tour_desc": "Mapea las carpetas, servicios y puntos de entrada.",
"starter.trace_title": "Rastrear una funcionalidad",
"starter.trace_desc": "Sigue una solicitud a través del código.",
"starter.fix_title": "Corregir un error",
"starter.fix_desc": "Descríbelo y enkly lo encuentra y lo corrige.",
"starter.tests_title": "Ejecutar las pruebas",
"starter.tests_desc": "Ejecuta la suite y explica qué falló.",
"tool.read": "Leer archivos",
"tool.write": "Escribir y editar",
"tool.search": "Buscar en el código",
"tool.run": "Ejecutar comandos permitidos",
"tool.subagents": "Subagentes",
"explore.tests": "Escribir pruebas para…",
"explore.review": "Revisar código",
"explore.explain": "Explicar un archivo",
"explore.document": "Redactar un documento",
"explore.refactor": "Refactorizar",
"composer.placeholder": "Pide a enkly que construya, corrija o explique cualquier cosa…",
"composer.send": "Enviar",
"composer.attach": "Añadir archivos o fotos",
"composer.hint_send": "enviar",
"composer.hint_newline": "nueva línea",
"composer.hint_drop": "Suelta archivos, pega capturas de pantalla o añade un proyecto .zip",
"auth.login_title": "Iniciar sesión",
"auth.login_button": "Iniciar sesión",
"auth.email_label": "Correo electrónico",
"auth.password_label": "Contraseña",
"auth.confirm_password_label": "Confirmar contraseña",
"auth.no_account": "¿No tienes una cuenta?",
"auth.signup_link": "Regístrate",
"auth.signup_title": "Crea tu cuenta",
"auth.signup_button": "Registrarse",
"auth.have_account": "¿Ya tienes una cuenta?",
"auth.login_link": "Iniciar sesión",
"auth.forgot_link": "¿Olvidaste tu contraseña?",
"auth.verify_title": "Verifica tu correo",
"auth.verify_code_label": "Código de 6 dígitos",
"auth.verify_button": "Verificar",
"auth.resend_link": "Reenviar código",
"auth.forgot_title": "Restablece tu contraseña",
"auth.forgot_button": "Enviar enlace de restablecimiento",
"auth.back_to_login": "Volver a iniciar sesión",
"auth.reset_title": "Elige una nueva contraseña",
"auth.reset_button": "Restablecer contraseña"
}

104
pkg/i18n/locales/fr.json Normal file
View File

@@ -0,0 +1,104 @@
{
"nav.new_chat": "Nouvelle discussion",
"nav.conversations": "Conversations",
"nav.no_conversations": "Aucune conversation pour l'instant.",
"nav.plan_suffix": "forfait",
"nav.manage": "Gérer",
"nav.log_out": "Déconnexion",
"nav.theme_light": "Clair",
"nav.theme_dark": "Sombre",
"nav.theme_auto": "Auto",
"nav.language": "Langue",
"nav.search_placeholder": "Rechercher des discussions, fichiers, actions…",
"nav.agent_ready": "Agent prêt",
"nav.rename_save": "Enregistrer le nom",
"nav.delete": "Supprimer",
"nav.close_sidebar": "Fermer la barre latérale",
"nav.open_sidebar": "Ouvrir la barre latérale",
"hero.title": "Que devons-nous construire aujourd'hui ?",
"hero.subtitle": "enkly travaille directement dans votre espace de travail : il lit, recherche, modifie et exécute du code, puis affiche chaque étape.",
"hero.start_with": "Commencer par",
"hero.tools_heading": "Ce qu'enkly peut utiliser",
"hero.explore_heading": "À explorer",
"starter.tour_title": "Visiter cet espace de travail",
"starter.tour_desc": "Cartographier les dossiers, services et points d'entrée.",
"starter.trace_title": "Suivre une fonctionnalité",
"starter.trace_desc": "Suivre une requête à travers le code.",
"starter.fix_title": "Corriger un bug",
"starter.fix_desc": "Décrivez-le, enkly le trouve et le corrige.",
"starter.tests_title": "Lancer les tests",
"starter.tests_desc": "Exécuter la suite de tests et expliquer les échecs.",
"tool.read": "Lire des fichiers",
"tool.write": "Écrire et modifier",
"tool.search": "Rechercher dans le code",
"tool.run": "Exécuter des commandes autorisées",
"tool.subagents": "Sous-agents",
"explore.tests": "Écrire des tests pour…",
"explore.review": "Relire du code",
"explore.explain": "Expliquer un fichier",
"explore.document": "Rédiger un document",
"explore.refactor": "Refactoriser",
"composer.placeholder": "Demandez à enkly de construire, corriger ou expliquer n'importe quoi…",
"composer.send": "Envoyer",
"composer.attach": "Ajouter des fichiers ou des photos",
"composer.hint_send": "envoyer",
"composer.hint_newline": "nouvelle ligne",
"composer.hint_drop": "Déposez des fichiers, collez des captures d'écran ou ajoutez un projet .zip",
"auth.login_title": "Se connecter",
"auth.login_button": "Se connecter",
"auth.email_label": "E-mail",
"auth.password_label": "Mot de passe",
"auth.confirm_password_label": "Confirmer le mot de passe",
"auth.no_account": "Vous n'avez pas de compte ?",
"auth.signup_link": "S'inscrire",
"auth.signup_title": "Créer votre compte",
"auth.signup_button": "S'inscrire",
"auth.have_account": "Vous avez déjà un compte ?",
"auth.login_link": "Se connecter",
"auth.forgot_link": "Mot de passe oublié ?",
"auth.verify_title": "Vérifiez votre e-mail",
"auth.verify_code_label": "Code à 6 chiffres",
"auth.verify_button": "Vérifier",
"auth.resend_link": "Renvoyer le code",
"auth.forgot_title": "Réinitialiser votre mot de passe",
"auth.forgot_button": "Envoyer le lien de réinitialisation",
"auth.back_to_login": "Retour à la connexion",
"auth.reset_title": "Choisissez un nouveau mot de passe",
"auth.reset_button": "Réinitialiser le mot de passe",
"errors.generic": "Une erreur est survenue. Veuillez réessayer.",
"errors.invalid_email": "Saisissez une adresse e-mail valide.",
"errors.password_min": "Le mot de passe doit comporter au moins %d caractères.",
"errors.password_max": "Le mot de passe doit comporter au plus %d caractères.",
"errors.password_mismatch": "Les mots de passe ne correspondent pas.",
"errors.incorrect_credentials": "E-mail ou mot de passe incorrect.",
"errors.invalid_code": "Saisissez le code à 6 chiffres.",
"errors.code_incorrect": "Ce code est incorrect ou a expiré.",
"errors.reset_link_invalid": "Ce lien de réinitialisation est invalide ou a expiré. Demandez-en un nouveau.",
"errors.csrf": "Jeton de sécurité invalide ou manquant — rechargez la page et réessayez.",
"info.check_inbox": "Consultez votre boîte de réception pour le code de vérification à 6 chiffres.",
"info.mail_not_configured": "La messagerie n'est pas configurée sur ce serveur — consultez le journal du serveur pour votre code de vérification.",
"info.resend_sent": "Si ce compte doit être vérifié, un nouveau code est en route.",
"info.email_verified": "Votre e-mail est vérifié — connectez-vous pour continuer.",
"info.password_reset_done": "Votre mot de passe a été réinitialisé — connectez-vous avec votre nouveau mot de passe.",
"info.forgot_sent": "Si cette adresse possède un compte, un lien de réinitialisation est en route.",
"chat.type_message_first": "Saisissez d'abord un message.",
"chat.message_too_long": "Ce message est trop long (limite de %d caractères).",
"chat.rate_limited": "Trop de requêtes — ralentissez un peu.",
"chat.conversation_gone": "Cette conversation n'existe plus. Démarrez une nouvelle discussion.",
"chat.conversation_busy": "enkly est encore en train de répondre dans cette conversation — attendez la fin, ou appuyez sur Arrêter.",
"chat.too_many_streams": "Vous avez déjà plusieurs réponses en cours. Attendez qu'une se termine ou appuyez sur Arrêter.",
"chat.save_failed": "Impossible d'enregistrer votre message. Veuillez réessayer.",
"chat.load_failed": "Impossible de charger la conversation. Veuillez réessayer.",
"chat.send_failed": "Une erreur est survenue. Veuillez réessayer.",
"billing.quota_exceeded": "Vous avez atteint la limite du forfait %s : %d messages par jour. Passez à un forfait supérieur depuis la page Abonnement pour en obtenir plus."
}

74
pkg/i18n/locales/pt.json Normal file
View File

@@ -0,0 +1,74 @@
{
"nav.new_chat": "Nova conversa",
"nav.conversations": "Conversas",
"nav.no_conversations": "Ainda não há conversas.",
"nav.plan_suffix": "plano",
"nav.manage": "Gerenciar",
"nav.log_out": "Sair",
"nav.theme_light": "Claro",
"nav.theme_dark": "Escuro",
"nav.theme_auto": "Automático",
"nav.language": "Idioma",
"nav.search_placeholder": "Pesquisar conversas, arquivos, ações…",
"nav.agent_ready": "Agente pronto",
"nav.rename_save": "Salvar nome",
"nav.delete": "Excluir",
"nav.close_sidebar": "Fechar barra lateral",
"nav.open_sidebar": "Abrir barra lateral",
"hero.title": "O que vamos construir hoje?",
"hero.subtitle": "enkly trabalha dentro do seu espaço de trabalho: lê, pesquisa, edita e executa código, mostrando cada etapa.",
"hero.start_with": "Comece com",
"hero.tools_heading": "O que o enkly pode usar",
"hero.explore_heading": "Mais para explorar",
"starter.tour_title": "Conhecer este espaço de trabalho",
"starter.tour_desc": "Mapeie as pastas, serviços e pontos de entrada.",
"starter.trace_title": "Rastrear uma funcionalidade",
"starter.trace_desc": "Acompanhe uma requisição pelo código.",
"starter.fix_title": "Corrigir um bug",
"starter.fix_desc": "Descreva-o, o enkly encontra e corrige.",
"starter.tests_title": "Executar os testes",
"starter.tests_desc": "Execute a suíte e explique o que falhou.",
"tool.read": "Ler arquivos",
"tool.write": "Escrever e editar",
"tool.search": "Pesquisar no código",
"tool.run": "Executar comandos permitidos",
"tool.subagents": "Subagentes",
"explore.tests": "Escrever testes para…",
"explore.review": "Revisar código",
"explore.explain": "Explicar um arquivo",
"explore.document": "Redigir um documento",
"explore.refactor": "Refatorar",
"composer.placeholder": "Peça ao enkly para construir, corrigir ou explicar qualquer coisa…",
"composer.send": "Enviar",
"composer.attach": "Adicionar arquivos ou fotos",
"composer.hint_send": "enviar",
"composer.hint_newline": "nova linha",
"composer.hint_drop": "Solte arquivos, cole capturas de tela ou adicione um projeto .zip",
"auth.login_title": "Entrar",
"auth.login_button": "Entrar",
"auth.email_label": "E-mail",
"auth.password_label": "Senha",
"auth.confirm_password_label": "Confirmar senha",
"auth.no_account": "Não tem uma conta?",
"auth.signup_link": "Cadastre-se",
"auth.signup_title": "Crie sua conta",
"auth.signup_button": "Cadastrar",
"auth.have_account": "Já tem uma conta?",
"auth.login_link": "Entrar",
"auth.forgot_link": "Esqueceu sua senha?",
"auth.verify_title": "Verifique seu e-mail",
"auth.verify_code_label": "Código de 6 dígitos",
"auth.verify_button": "Verificar",
"auth.resend_link": "Reenviar código",
"auth.forgot_title": "Redefinir sua senha",
"auth.forgot_button": "Enviar link de redefinição",
"auth.back_to_login": "Voltar para o login",
"auth.reset_title": "Escolha uma nova senha",
"auth.reset_button": "Redefinir senha"
}

64
pkg/i18n/negotiate.go Normal file
View File

@@ -0,0 +1,64 @@
package i18n
import (
"sort"
"strconv"
"strings"
)
// weightedTag is one entry of a parsed Accept-Language header.
type weightedTag struct {
primary string // "fr" out of "fr-FR"
q float64
}
// parseAcceptLanguage turns a raw "Accept-Language" header value (RFC
// 9110 §12.5.4 — e.g. "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7") into its
// tags' primary subtags, ordered by descending quality weight. A
// malformed segment is skipped rather than aborting the whole parse —
// browsers have been known to send odd values, and the worst case of
// ignoring one bad segment is falling back to Default, never a panic
// or a rejected request.
func parseAcceptLanguage(header string) []weightedTag {
var tags []weightedTag
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
tag, qStr, hasQ := strings.Cut(part, ";")
tag = strings.TrimSpace(tag)
if tag == "" || tag == "*" {
continue
}
q := 1.0
if hasQ {
qStr = strings.TrimSpace(qStr)
if v, ok := strings.CutPrefix(qStr, "q="); ok {
if parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
q = parsed
}
}
}
primary, _, _ := strings.Cut(tag, "-")
tags = append(tags, weightedTag{primary: strings.ToLower(primary), q: q})
}
sort.SliceStable(tags, func(i, j int) bool { return tags[i].q > tags[j].q })
return tags
}
// NegotiateAcceptLanguage picks the best Supported locale for a raw
// "Accept-Language" header value, falling back to Default if nothing
// in the header is one enkly ships a catalog for (or the header is
// empty / unparseable). Callers only reach for this when there's no
// stronger signal available — see the gateway's localeFrom, which
// tries the enkly_lang cookie first and only falls through to this
// for a visitor who has never touched the language switcher.
func NegotiateAcceptLanguage(header string) Locale {
for _, tag := range parseAcceptLanguage(header) {
if IsSupported(tag.primary) {
return Locale(tag.primary)
}
}
return Default
}

View File

@@ -0,0 +1,20 @@
package i18n
import "testing"
func TestNegotiateAcceptLanguage(t *testing.T) {
cases := map[string]Locale{
"": Default,
"fr-FR,fr;q=0.9,en;q=0.8": "fr",
"de;q=0.5,fr;q=0.9": "fr",
"xx-XX,yy-YY": Default,
"pt-BR": "pt",
"en-US,en;q=0.9": "en",
" , ;q=bogus , es-MX;q=0.4": "es",
}
for header, want := range cases {
if got := NegotiateAcceptLanguage(header); got != want {
t.Errorf("NegotiateAcceptLanguage(%q) = %q, want %q", header, got, want)
}
}
}

173
pkg/mailkit/mail.go Normal file
View File

@@ -0,0 +1,173 @@
// Package mail sends outgoing email (verification codes and password
// reset links) over SMTP with STARTTLS — the scheme smtp.gmail.com
// requires on port 587. It's built on net/smtp + crypto/tls directly
// rather than a third-party mailer library, matching the rest of
// enkly's minimal-dependencies stance (see go.mod).
package mailkit
import (
"crypto/tls"
"fmt"
"net"
"net/mail"
"net/smtp"
"strings"
"time"
)
// Config holds SMTP connection details, normally sourced from the
// ENKLY_SMTP_* environment variables (see cmd/server/main.go and
// .env.example). Host/Port default to Gmail's submission endpoint;
// Username/Password should be a Gmail *App Password*
// (https://myaccount.google.com/apppasswords), not the account's normal
// login password — Gmail rejects plain-password SMTP auth for accounts
// with 2FA, and discourages it even without 2FA.
type Config struct {
Host string // e.g. "smtp.gmail.com"
Port string // e.g. "587"
Username string // full Gmail address
Password string // Gmail App Password
// From is used for the message's From: header, and may be either a
// bare address ("noreply@example.com") or a full RFC 5322 mailbox
// with a display name ("enkly <noreply@example.com>") — Send picks
// the bare address back out of either form for the SMTP envelope
// (see envelopeAddress), since MAIL FROM must never be given a
// display name. Defaults to Username if empty.
From string
}
// Mailer sends email via a configured SMTP server.
type Mailer struct {
cfg Config
}
// New builds a Mailer from cfg, filling in Gmail's defaults for any
// blank Host/Port so a minimal .env (just username + app password)
// works out of the box. Every field is also trimmed of surrounding
// whitespace, including a stray trailing "\r" — a real failure mode
// when an operator's .env or docker-compose.yml was saved with CRLF
// line endings and the value reaches os.Getenv un-normalized: an
// embedded "\r" in an SMTP command line reads to the server as a
// premature line break, and Gmail answers with a "555 Syntax error"
// that gives no hint the cause was whitespace.
func New(cfg Config) *Mailer {
cfg.Host = strings.TrimSpace(cfg.Host)
cfg.Port = strings.TrimSpace(cfg.Port)
cfg.Username = strings.TrimSpace(cfg.Username)
cfg.Password = strings.TrimSpace(cfg.Password)
cfg.From = strings.TrimSpace(cfg.From)
if cfg.Host == "" {
cfg.Host = "smtp.gmail.com"
}
if cfg.Port == "" {
cfg.Port = "587"
}
if cfg.From == "" {
cfg.From = cfg.Username
}
return &Mailer{cfg: cfg}
}
// Configured reports whether enough SMTP settings are present to
// actually send mail. main.go logs a warning instead of failing to
// start when this is false, so the rest of the app (chat, RAG) keeps
// working even if email hasn't been set up yet — signup will just fail
// with a clear error until it is.
func (m *Mailer) Configured() bool {
return m.cfg.Username != "" && m.cfg.Password != ""
}
// Send delivers a plain-text email to a single recipient, using
// STARTTLS on the configured host/port (Gmail requires this on 587;
// plaintext SMTP is refused). Uses net/smtp's low-level Client instead
// of smtp.SendMail because SendMail only supports implicit TLS (port
// 465) or no TLS at all, not STARTTLS-then-auth.
func (m *Mailer) Send(to, subject, body string) error {
if !m.Configured() {
return fmt.Errorf("mail: not configured (set ENKLY_SMTP_USERNAME / ENKLY_SMTP_PASSWORD)")
}
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("mail: dial %s: %w", addr, err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, m.cfg.Host)
if err != nil {
return fmt.Errorf("mail: smtp handshake: %w", err)
}
defer client.Close()
if ok, _ := client.Extension("STARTTLS"); ok {
tlsConfig := &tls.Config{ServerName: m.cfg.Host}
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("mail: starttls: %w", err)
}
}
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
if ok, _ := client.Extension("AUTH"); ok {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("mail: auth: %w", err)
}
}
if err := client.Mail(envelopeAddress(m.cfg.From)); err != nil {
return fmt.Errorf("mail: MAIL FROM: %w", err)
}
if err := client.Rcpt(envelopeAddress(to)); err != nil {
return fmt.Errorf("mail: RCPT TO: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("mail: DATA: %w", err)
}
msg := buildMessage(m.cfg.From, to, subject, body)
if _, err := w.Write([]byte(msg)); err != nil {
return fmt.Errorf("mail: write body: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("mail: close body: %w", err)
}
return client.Quit()
}
// buildMessage assembles a minimal RFC 5322 message. Headers are kept
// deliberately simple (plain text, no MIME multipart) since enkly only
// ever sends short verification emails.
func buildMessage(from, to, subject, body string) string {
var b strings.Builder
fmt.Fprintf(&b, "From: %s\r\n", from)
fmt.Fprintf(&b, "To: %s\r\n", to)
fmt.Fprintf(&b, "Subject: %s\r\n", subject)
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
b.WriteString("\r\n")
b.WriteString(body)
b.WriteString("\r\n")
return b.String()
}
// envelopeAddress reduces addr to the bare mailbox SMTP's envelope
// commands require (RFC 5321 §4.1.2 reverse-/forward-path: an address
// only, never a display name or angle brackets). addr may already be
// bare ("user@example.com") or a full RFC 5322 mailbox with a display
// name ("enkly <user@example.com>") — either way client.Mail/client.Rcpt
// wrap it in its own "<...>", so passing a mailbox that already
// contains a display name and brackets there produces something like
// "MAIL FROM:<enkly <user@example.com>>", which Gmail rejects with
// exactly "555 5.5.2 Syntax error, cannot decode response" — the bug
// this function exists to prevent. If addr doesn't parse as an address
// at all, it's returned unchanged so the SMTP server's own error (not a
// swallowed one here) explains what's wrong with it.
func envelopeAddress(addr string) string {
if parsed, err := mail.ParseAddress(addr); err == nil {
return parsed.Address
}
return addr
}

99
pkg/mailkit/mail_test.go Normal file
View File

@@ -0,0 +1,99 @@
package mailkit
import (
"strings"
"testing"
)
// TestEnvelopeAddress covers the exact bug this fixes: a configured
// From address that includes a display name (a very natural thing to
// set ENKLY_SMTP_FROM to, e.g. "enkly <noreply@example.com>") must not
// be handed to MAIL FROM/RCPT TO as-is, or Gmail answers with "555
// 5.5.2 Syntax error, cannot decode response" because the envelope
// command only accepts a bare address (RFC 5321 §4.1.2).
func TestEnvelopeAddress(t *testing.T) {
cases := []struct{ in, want string }{
{"user@example.com", "user@example.com"},
{"enkly <user@example.com>", "user@example.com"},
{" enkly <user@example.com> ", "user@example.com"},
{"\"Enkly Notifications\" <user@example.com>", "user@example.com"},
// Not parseable as an address at all: returned unchanged so the
// SMTP server's own rejection explains the problem, rather than
// this function silently swallowing it.
{"not-an-address", "not-an-address"},
}
for _, c := range cases {
if got := envelopeAddress(c.in); got != c.want {
t.Errorf("envelopeAddress(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestNewTrimsConfig guards against the same class of bug from a
// different angle: SMTP config values arriving from os.Getenv with
// stray whitespace or an embedded "\r" (e.g. a CRLF-saved .env or
// docker-compose.yml) must not reach the wire as part of an SMTP
// command line, or the server sees a corrupted command.
func TestNewTrimsConfig(t *testing.T) {
m := New(Config{
Host: " smtp.gmail.com\r\n",
Port: " 587 ",
Username: " user@example.com \r",
Password: " app-password \r",
From: " enkly <user@example.com> \r",
})
if m.cfg.Host != "smtp.gmail.com" {
t.Errorf("Host = %q", m.cfg.Host)
}
if m.cfg.Port != "587" {
t.Errorf("Port = %q", m.cfg.Port)
}
if m.cfg.Username != "user@example.com" {
t.Errorf("Username = %q", m.cfg.Username)
}
if m.cfg.Password != "app-password" {
t.Errorf("Password = %q", m.cfg.Password)
}
if m.cfg.From != "enkly <user@example.com>" {
t.Errorf("From = %q", m.cfg.From)
}
}
// TestNewDefaults checks the Gmail host/port/from defaults still apply
// after adding trimming, so a minimal config keeps working out of the
// box.
func TestNewDefaults(t *testing.T) {
m := New(Config{Username: "user@example.com", Password: "app-password"})
if m.cfg.Host != "smtp.gmail.com" {
t.Errorf("default Host = %q", m.cfg.Host)
}
if m.cfg.Port != "587" {
t.Errorf("default Port = %q", m.cfg.Port)
}
if m.cfg.From != "user@example.com" {
t.Errorf("default From = %q", m.cfg.From)
}
}
func TestConfigured(t *testing.T) {
if New(Config{}).Configured() {
t.Fatal("empty config must not report Configured")
}
if !New(Config{Username: "u", Password: "p"}).Configured() {
t.Fatal("username+password must report Configured")
}
}
func TestBuildMessageShape(t *testing.T) {
msg := buildMessage("enkly <from@example.com>", "to@example.com", "Subject line", "body text")
for _, want := range []string{
"From: enkly <from@example.com>\r\n",
"To: to@example.com\r\n",
"Subject: Subject line\r\n",
"\r\nbody text\r\n",
} {
if !strings.Contains(msg, want) {
t.Errorf("buildMessage output missing %q; got:\n%s", want, msg)
}
}
}

115
pkg/mathkit/calculus.go Normal file
View File

@@ -0,0 +1,115 @@
package mathkit
import "fmt"
// defaultStep is the finite-difference step size used when the
// caller doesn't specify one. 1e-5 is the standard sweet spot for
// float64 central differences: small enough for truncation error to
// be negligible, large enough that it doesn't get swallowed by
// float64 rounding error (the classic bias-variance trade-off of
// numerical differentiation).
const defaultStep = 1e-5
// Derivative estimates d/d(variable) of expr at the point `at`
// (variable -> value, including any other free variables the
// expression needs) using the central-difference formula
// (f(x+h)-f(x-h))/(2h), which is second-order accurate — far better
// than the naive forward difference (f(x+h)-f(x))/h for the same h.
// h<=0 uses defaultStep.
func Derivative(expr *Expr, variable string, at map[string]float64, h float64) (float64, error) {
if h <= 0 {
h = defaultStep
}
x0, ok := at[variable]
if !ok {
return 0, fmt.Errorf("derivative: %q is not bound in \"at\"", variable)
}
plus := cloneVars(at)
plus[variable] = x0 + h
minus := cloneVars(at)
minus[variable] = x0 - h
fPlus, err := expr.Eval(plus)
if err != nil {
return 0, fmt.Errorf("derivative: evaluating at %s+h: %w", variable, err)
}
fMinus, err := expr.Eval(minus)
if err != nil {
return 0, fmt.Errorf("derivative: evaluating at %s-h: %w", variable, err)
}
return (fPlus - fMinus) / (2 * h), nil
}
// Gradient estimates ∇f at the point `at` with respect to each name
// in variables, in order — the central operation every ML training
// loop runs millions of times (just normally via backprop's chain
// rule rather than finite differences; this is the numerical
// stand-in that's useful for checking a hand-derived gradient, or for
// expressions where deriving the symbolic gradient isn't worth it).
func Gradient(expr *Expr, variables []string, at map[string]float64, h float64) ([]float64, error) {
if len(variables) == 0 {
return nil, fmt.Errorf("gradient: \"variables\" must list at least one variable")
}
grad := make([]float64, len(variables))
for i, v := range variables {
d, err := Derivative(expr, v, at, h)
if err != nil {
return nil, err
}
grad[i] = d
}
return grad, nil
}
func cloneVars(m map[string]float64) map[string]float64 {
out := make(map[string]float64, len(m))
for k, v := range m {
out[k] = v
}
return out
}
// Integrate estimates the definite integral of expr with respect to
// variable over [from, to] using Simpson's rule, which is exact for
// up to cubic polynomials and converges much faster than the
// trapezoid rule for smooth functions. steps (the number of
// subintervals) is rounded up to the nearest even number, since
// Simpson's rule needs an even number of subintervals; steps<=0
// defaults to 1000.
func Integrate(expr *Expr, variable string, from, to float64, steps int, fixed map[string]float64) (float64, error) {
if steps <= 0 {
steps = 1000
}
if steps%2 != 0 {
steps++
}
h := (to - from) / float64(steps)
vars := cloneVars(fixed)
eval := func(x float64) (float64, error) {
vars[variable] = x
return expr.Eval(vars)
}
f0, err := eval(from)
if err != nil {
return 0, fmt.Errorf("integral: %w", err)
}
fn, err := eval(to)
if err != nil {
return 0, fmt.Errorf("integral: %w", err)
}
sum := f0 + fn
for i := 1; i < steps; i++ {
x := from + float64(i)*h
fx, err := eval(x)
if err != nil {
return 0, fmt.Errorf("integral: %w", err)
}
if i%2 == 0 {
sum += 2 * fx
} else {
sum += 4 * fx
}
}
return sum * h / 3, nil
}

421
pkg/mathkit/expr.go Normal file
View File

@@ -0,0 +1,421 @@
// Package mathkit is a dependency-free math engine for the problems
// that actually come up when building and debugging ML code: linear
// algebra (the shape of every tensor op), calculus (gradients are how
// everything trains), probability/statistics (data, metrics, losses),
// numerical optimization (gradient descent, root finding), and the
// handful of ML-specific primitives (activations, loss functions,
// least-squares linear regression) that sit on top of those four
// pillars. It has no third-party dependency — see the comment on
// pkg/envkit/dotenv.go for why this module keeps go.mod minimal — so
// the whole engine is just the Go standard library's math/sort/fmt.
//
// Every sub-area (linalg.go, calculus.go, statistics.go, optimize.go,
// ml.go) is usable directly from Go code. solve.go adds one more
// layer on top: Solve(operation, params) dispatches a string
// operation name and a loosely-typed params map (exactly the shape
// a JSON tool-call argument object decodes to) to the right function
// and renders a human-readable report — that's the layer the agent
// service's solve_ml_math tool calls (see
// services/agent/internal/infrastructure/toolexec/mlmath.go), because
// an LLM tool call arrives as JSON, not as typed Go arguments.
package mathkit
import (
"fmt"
"math"
"strings"
"unicode"
)
// Expr is a parsed arithmetic expression that can be evaluated
// repeatedly against different variable bindings without re-parsing —
// exactly what Gradient and GradientDescent need, since they evaluate
// the same expression at many points.
type Expr struct {
root node
src string
}
// String returns the original source text the expression was parsed
// from, for error messages and report rendering.
func (e *Expr) String() string { return e.src }
// node is the parsed AST. A tiny tagged union kept as an interface
// rather than a struct-with-a-kind-field: each node type carries only
// the fields it needs, and eval is a single recursive method.
type node interface {
eval(vars map[string]float64) (float64, error)
}
type numNode float64
func (n numNode) eval(map[string]float64) (float64, error) { return float64(n), nil }
type varNode string
func (n varNode) eval(vars map[string]float64) (float64, error) {
v, ok := vars[string(n)]
if !ok {
return 0, fmt.Errorf("unbound variable %q (pass it in \"variables\" or \"at\")", string(n))
}
return v, nil
}
type binNode struct {
op byte // '+', '-', '*', '/', '^'
lhs, rhs node
}
func (n binNode) eval(vars map[string]float64) (float64, error) {
l, err := n.lhs.eval(vars)
if err != nil {
return 0, err
}
r, err := n.rhs.eval(vars)
if err != nil {
return 0, err
}
switch n.op {
case '+':
return l + r, nil
case '-':
return l - r, nil
case '*':
return l * r, nil
case '/':
if r == 0 {
return 0, fmt.Errorf("division by zero")
}
return l / r, nil
case '^':
return math.Pow(l, r), nil
}
return 0, fmt.Errorf("internal: unknown operator %q", n.op)
}
type negNode struct{ x node }
func (n negNode) eval(vars map[string]float64) (float64, error) {
v, err := n.x.eval(vars)
return -v, err
}
type callNode struct {
fn string
args []node
}
// unaryFuncs are functions of exactly one argument — the overwhelming
// majority of what shows up in ML math (activations, logs, norms).
var unaryFuncs = map[string]func(float64) float64{
"sin": math.Sin, "cos": math.Cos, "tan": math.Tan,
"asin": math.Asin, "acos": math.Acos, "atan": math.Atan,
"sinh": math.Sinh, "cosh": math.Cosh, "tanh": math.Tanh,
"exp": math.Exp, "sqrt": math.Sqrt, "abs": math.Abs,
"ln": math.Log, "log": math.Log, "log2": math.Log2, "log10": math.Log10,
"floor": math.Floor, "ceil": math.Ceil, "round": math.Round,
"sigmoid": Sigmoid, "relu": ReLU, "sign": func(x float64) float64 { return math.Copysign(1, x) * boolF(x != 0) },
}
func boolF(b bool) float64 {
if b {
return 1
}
return 0
}
// binaryFuncs are functions of exactly two arguments.
var binaryFuncs = map[string]func(float64, float64) float64{
"max": math.Max, "min": math.Min, "pow": math.Pow,
"mod": math.Mod, "atan2": math.Atan2,
}
func (n callNode) eval(vars map[string]float64) (float64, error) {
switch len(n.args) {
case 1:
if f, ok := unaryFuncs[n.fn]; ok {
v, err := n.args[0].eval(vars)
if err != nil {
return 0, err
}
return f(v), nil
}
case 2:
if f, ok := binaryFuncs[n.fn]; ok {
a, err := n.args[0].eval(vars)
if err != nil {
return 0, err
}
b, err := n.args[1].eval(vars)
if err != nil {
return 0, err
}
return f(a, b), nil
}
}
return 0, fmt.Errorf("unknown function %q with %d argument(s)", n.fn, len(n.args))
}
// Eval evaluates the expression against a variable binding. Missing
// variables are a reported error (never silently treated as zero),
// since a silent zero would turn a typo into a wrong-but-plausible
// answer — exactly the failure mode numerical tools must not have.
func (e *Expr) Eval(vars map[string]float64) (float64, error) {
return e.root.eval(vars)
}
// ParseExpr parses a math expression written in ordinary infix
// notation: + - * / ^ (right-associative power), unary minus,
// parentheses, named variables, the constants pi/e, and function
// calls (sin(x), sigmoid(x), max(a,b), ...). See unaryFuncs and
// binaryFuncs for the full function list.
func ParseExpr(src string) (*Expr, error) {
p := &parser{toks: tokenize(src)}
n, err := p.parseExpr()
if err != nil {
return nil, fmt.Errorf("parsing %q: %w", src, err)
}
if p.pos != len(p.toks) {
return nil, fmt.Errorf("parsing %q: unexpected trailing input near %q", src, p.toks[p.pos].text)
}
return &Expr{root: n, src: src}, nil
}
// --- tokenizer ---
type tokKind int
const (
tokNum tokKind = iota
tokIdent
tokOp
tokLParen
tokRParen
tokComma
)
type token struct {
kind tokKind
text string
num float64
}
func tokenize(src string) []token {
var toks []token
r := []rune(src)
i := 0
for i < len(r) {
c := r[i]
switch {
case unicode.IsSpace(c):
i++
case c == '(':
toks = append(toks, token{kind: tokLParen, text: "("})
i++
case c == ')':
toks = append(toks, token{kind: tokRParen, text: ")"})
i++
case c == ',':
toks = append(toks, token{kind: tokComma, text: ","})
i++
case strings.ContainsRune("+-*/^", c):
toks = append(toks, token{kind: tokOp, text: string(c)})
i++
case unicode.IsDigit(c) || c == '.':
j := i
for j < len(r) && (unicode.IsDigit(r[j]) || r[j] == '.') {
j++
}
// scientific notation, e.g. 1e-3
if j < len(r) && (r[j] == 'e' || r[j] == 'E') {
k := j + 1
if k < len(r) && (r[k] == '+' || r[k] == '-') {
k++
}
if k < len(r) && unicode.IsDigit(r[k]) {
j = k
for j < len(r) && unicode.IsDigit(r[j]) {
j++
}
}
}
text := string(r[i:j])
var f float64
fmt.Sscanf(text, "%g", &f)
toks = append(toks, token{kind: tokNum, text: text, num: f})
i = j
case unicode.IsLetter(c) || c == '_':
j := i
for j < len(r) && (unicode.IsLetter(r[j]) || unicode.IsDigit(r[j]) || r[j] == '_') {
j++
}
toks = append(toks, token{kind: tokIdent, text: string(r[i:j])})
i = j
default:
// Unknown character: emit as its own operator token so the
// parser reports a clear "unexpected token" error instead
// of the tokenizer silently dropping it.
toks = append(toks, token{kind: tokOp, text: string(c)})
i++
}
}
return toks
}
// --- recursive-descent parser ---
//
// expr := term (('+' | '-') term)*
// term := unary (('*' | '/') unary)*
// unary := ('-' | '+') unary | power
// power := primary ('^' unary)? -- right-associative; the
// exponent recurses through unary (not power) so the
// precedence matches ordinary math/Python convention:
// unary minus binds LOOSER than '^' (-2^2 == -(2^2) == -4)
// but the exponent itself may carry its own sign
// (2^-2 == 0.25).
// primary:= number | const | ident '(' args ')' | ident | '(' expr ')'
// args := expr (',' expr)*
type parser struct {
toks []token
pos int
}
func (p *parser) peek() (token, bool) {
if p.pos >= len(p.toks) {
return token{}, false
}
return p.toks[p.pos], true
}
func (p *parser) next() (token, bool) {
t, ok := p.peek()
if ok {
p.pos++
}
return t, ok
}
func (p *parser) parseExpr() (node, error) {
lhs, err := p.parseTerm()
if err != nil {
return nil, err
}
for {
t, ok := p.peek()
if !ok || t.kind != tokOp || (t.text != "+" && t.text != "-") {
return lhs, nil
}
p.next()
rhs, err := p.parseTerm()
if err != nil {
return nil, err
}
lhs = binNode{op: t.text[0], lhs: lhs, rhs: rhs}
}
}
func (p *parser) parseTerm() (node, error) {
lhs, err := p.parseUnary()
if err != nil {
return nil, err
}
for {
t, ok := p.peek()
if !ok || t.kind != tokOp || (t.text != "*" && t.text != "/") {
return lhs, nil
}
p.next()
rhs, err := p.parseUnary()
if err != nil {
return nil, err
}
lhs = binNode{op: t.text[0], lhs: lhs, rhs: rhs}
}
}
func (p *parser) parseUnary() (node, error) {
if t, ok := p.peek(); ok && t.kind == tokOp && (t.text == "-" || t.text == "+") {
p.next()
x, err := p.parseUnary()
if err != nil {
return nil, err
}
if t.text == "-" {
return negNode{x: x}, nil
}
return x, nil
}
return p.parsePower()
}
func (p *parser) parsePower() (node, error) {
lhs, err := p.parsePrimary()
if err != nil {
return nil, err
}
if t, ok := p.peek(); ok && t.kind == tokOp && t.text == "^" {
p.next()
rhs, err := p.parseUnary() // right-associative; lets the exponent carry its own sign
if err != nil {
return nil, err
}
return binNode{op: '^', lhs: lhs, rhs: rhs}, nil
}
return lhs, nil
}
func (p *parser) parsePrimary() (node, error) {
t, ok := p.next()
if !ok {
return nil, fmt.Errorf("unexpected end of expression")
}
switch t.kind {
case tokNum:
return numNode(t.num), nil
case tokLParen:
n, err := p.parseExpr()
if err != nil {
return nil, err
}
if c, ok := p.next(); !ok || c.kind != tokRParen {
return nil, fmt.Errorf("missing closing ')'")
}
return n, nil
case tokIdent:
switch strings.ToLower(t.text) {
case "pi":
return numNode(math.Pi), nil
case "e":
return numNode(math.E), nil
}
if nx, ok := p.peek(); ok && nx.kind == tokLParen {
p.next()
var args []node
if c, ok := p.peek(); !ok || c.kind != tokRParen {
for {
a, err := p.parseExpr()
if err != nil {
return nil, err
}
args = append(args, a)
c, ok := p.peek()
if !ok {
return nil, fmt.Errorf("missing closing ')' in call to %s", t.text)
}
if c.kind == tokComma {
p.next()
continue
}
break
}
}
if c, ok := p.next(); !ok || c.kind != tokRParen {
return nil, fmt.Errorf("missing closing ')' in call to %s", t.text)
}
return callNode{fn: strings.ToLower(t.text), args: args}, nil
}
return varNode(t.text), nil
}
return nil, fmt.Errorf("unexpected token %q", t.text)
}

409
pkg/mathkit/linalg.go Normal file
View File

@@ -0,0 +1,409 @@
package mathkit
import (
"fmt"
"math"
)
// Matrix is a dense matrix stored row-major: Matrix[i][j] is row i,
// column j. Every row must have the same length — functions that take
// a Matrix validate that with Matrix.Dims before doing any math, so a
// ragged input is reported as an error rather than panicking.
type Matrix [][]float64
// Dims returns the matrix's row and column counts and an error if the
// rows aren't all the same length (a ragged "matrix" isn't one).
func (m Matrix) Dims() (rows, cols int, err error) {
rows = len(m)
if rows == 0 {
return 0, 0, fmt.Errorf("empty matrix")
}
cols = len(m[0])
for i, row := range m {
if len(row) != cols {
return 0, 0, fmt.Errorf("ragged matrix: row 0 has %d columns, row %d has %d", cols, i, len(row))
}
}
return rows, cols, nil
}
// Identity returns the n×n identity matrix.
func Identity(n int) Matrix {
m := make(Matrix, n)
for i := range m {
m[i] = make([]float64, n)
m[i][i] = 1
}
return m
}
func sameDims(a, b Matrix) (rows, cols int, err error) {
ra, ca, err := a.Dims()
if err != nil {
return 0, 0, err
}
rb, cb, err := b.Dims()
if err != nil {
return 0, 0, err
}
if ra != rb || ca != cb {
return 0, 0, fmt.Errorf("shape mismatch: %dx%d vs %dx%d", ra, ca, rb, cb)
}
return ra, ca, nil
}
// MatAdd returns a+b, element-wise.
func MatAdd(a, b Matrix) (Matrix, error) {
rows, cols, err := sameDims(a, b)
if err != nil {
return nil, fmt.Errorf("matrix_add: %w", err)
}
out := make(Matrix, rows)
for i := 0; i < rows; i++ {
out[i] = make([]float64, cols)
for j := 0; j < cols; j++ {
out[i][j] = a[i][j] + b[i][j]
}
}
return out, nil
}
// MatSub returns a-b, element-wise.
func MatSub(a, b Matrix) (Matrix, error) {
rows, cols, err := sameDims(a, b)
if err != nil {
return nil, fmt.Errorf("matrix_sub: %w", err)
}
out := make(Matrix, rows)
for i := 0; i < rows; i++ {
out[i] = make([]float64, cols)
for j := 0; j < cols; j++ {
out[i][j] = a[i][j] - b[i][j]
}
}
return out, nil
}
// MatMul returns the matrix product a×b (standard inner-dimension
// matching: a is m×n, b must be n×p).
func MatMul(a, b Matrix) (Matrix, error) {
ra, ca, err := a.Dims()
if err != nil {
return nil, fmt.Errorf("matrix_mul: left operand: %w", err)
}
rb, cb, err := b.Dims()
if err != nil {
return nil, fmt.Errorf("matrix_mul: right operand: %w", err)
}
if ca != rb {
return nil, fmt.Errorf("matrix_mul: inner dimensions must match: %dx%d times %dx%d", ra, ca, rb, cb)
}
out := make(Matrix, ra)
for i := 0; i < ra; i++ {
out[i] = make([]float64, cb)
for j := 0; j < cb; j++ {
var sum float64
for k := 0; k < ca; k++ {
sum += a[i][k] * b[k][j]
}
out[i][j] = sum
}
}
return out, nil
}
// Scale multiplies every element of m by k.
func Scale(m Matrix, k float64) Matrix {
out := make(Matrix, len(m))
for i, row := range m {
out[i] = make([]float64, len(row))
for j, v := range row {
out[i][j] = v * k
}
}
return out
}
// Transpose returns m^T.
func Transpose(m Matrix) (Matrix, error) {
rows, cols, err := m.Dims()
if err != nil {
return nil, fmt.Errorf("matrix_transpose: %w", err)
}
out := make(Matrix, cols)
for j := 0; j < cols; j++ {
out[j] = make([]float64, rows)
for i := 0; i < rows; i++ {
out[j][i] = m[i][j]
}
}
return out, nil
}
// Trace returns the sum of the diagonal of a square matrix.
func Trace(m Matrix) (float64, error) {
rows, cols, err := m.Dims()
if err != nil {
return 0, fmt.Errorf("matrix_trace: %w", err)
}
if rows != cols {
return 0, fmt.Errorf("matrix_trace: matrix must be square, got %dx%d", rows, cols)
}
var sum float64
for i := 0; i < rows; i++ {
sum += m[i][i]
}
return sum, nil
}
// clone makes a deep copy so Gaussian-elimination routines never
// mutate the caller's matrix.
func (m Matrix) clone() Matrix {
out := make(Matrix, len(m))
for i, row := range m {
out[i] = append([]float64(nil), row...)
}
return out
}
// luDecompose runs Gaussian elimination with partial pivoting on a
// clone of m, returning the resulting upper-triangular-ish matrix and
// the sign of the permutation (needed to get the determinant's sign
// right) and the pivots used. Both Determinant and Inverse build on
// this one routine rather than duplicating elimination logic.
func luDecompose(m Matrix) (a Matrix, sign float64, err error) {
rows, cols, err := m.Dims()
if err != nil {
return nil, 0, err
}
if rows != cols {
return nil, 0, fmt.Errorf("matrix must be square, got %dx%d", rows, cols)
}
n := rows
a = m.clone()
sign = 1
for col := 0; col < n; col++ {
// Partial pivot: swap in the row with the largest absolute
// value in this column, for numerical stability.
pivotRow := col
best := math.Abs(a[col][col])
for r := col + 1; r < n; r++ {
if v := math.Abs(a[r][col]); v > best {
best, pivotRow = v, r
}
}
if best < 1e-12 {
return a, 0, nil // singular; determinant is 0, caller decides
}
if pivotRow != col {
a[col], a[pivotRow] = a[pivotRow], a[col]
sign = -sign
}
for r := col + 1; r < n; r++ {
factor := a[r][col] / a[col][col]
for c := col; c < n; c++ {
a[r][c] -= factor * a[col][c]
}
}
}
return a, sign, nil
}
// Determinant computes det(m) via LU (Gaussian elimination with
// partial pivoting), O(n^3) — the standard numerically-stable
// approach, versus the O(n!) cofactor expansion a direct reading of
// the definition would suggest.
func Determinant(m Matrix) (float64, error) {
a, sign, err := luDecompose(m)
if err != nil {
return 0, fmt.Errorf("matrix_determinant: %w", err)
}
n := len(a)
det := sign
for i := 0; i < n; i++ {
det *= a[i][i]
}
return det, nil
}
// Inverse computes m^-1 via Gauss-Jordan elimination on [m | I].
// Returns an error for a non-square or singular (determinant ≈ 0)
// matrix — ML code hitting a singular matrix (e.g. a rank-deficient
// design matrix in least squares) usually wants to know that, not a
// silently garbage answer.
func Inverse(m Matrix) (Matrix, error) {
rows, cols, err := m.Dims()
if err != nil {
return nil, fmt.Errorf("matrix_inverse: %w", err)
}
if rows != cols {
return nil, fmt.Errorf("matrix_inverse: matrix must be square, got %dx%d", rows, cols)
}
n := rows
aug := make(Matrix, n)
for i := 0; i < n; i++ {
aug[i] = make([]float64, 2*n)
copy(aug[i], m[i])
aug[i][n+i] = 1
}
for col := 0; col < n; col++ {
pivotRow := col
best := math.Abs(aug[col][col])
for r := col + 1; r < n; r++ {
if v := math.Abs(aug[r][col]); v > best {
best, pivotRow = v, r
}
}
if best < 1e-12 {
return nil, fmt.Errorf("matrix_inverse: matrix is singular (not invertible)")
}
aug[col], aug[pivotRow] = aug[pivotRow], aug[col]
pivot := aug[col][col]
for c := 0; c < 2*n; c++ {
aug[col][c] /= pivot
}
for r := 0; r < n; r++ {
if r == col {
continue
}
factor := aug[r][col]
for c := 0; c < 2*n; c++ {
aug[r][c] -= factor * aug[col][c]
}
}
}
out := make(Matrix, n)
for i := 0; i < n; i++ {
out[i] = append([]float64(nil), aug[i][n:]...)
}
return out, nil
}
// Rank computes the matrix rank as the number of non-negligible
// pivots found by Gaussian elimination (works for non-square
// matrices too, unlike Determinant/Inverse).
func Rank(m Matrix) (int, error) {
rows, cols, err := m.Dims()
if err != nil {
return 0, fmt.Errorf("matrix_rank: %w", err)
}
a := m.clone()
rank := 0
for col := 0; col < cols && rank < rows; col++ {
pivotRow := -1
best := 1e-9
for r := rank; r < rows; r++ {
if v := math.Abs(a[r][col]); v > best {
best, pivotRow = v, r
}
}
if pivotRow == -1 {
continue
}
a[rank], a[pivotRow] = a[pivotRow], a[rank]
for r := rank + 1; r < rows; r++ {
factor := a[r][col] / a[rank][col]
for c := col; c < cols; c++ {
a[r][c] -= factor * a[rank][c]
}
}
rank++
}
return rank, nil
}
// MatVecMul returns m×v for a column vector v.
func MatVecMul(m Matrix, v []float64) ([]float64, error) {
rows, cols, err := m.Dims()
if err != nil {
return nil, fmt.Errorf("matrix-vector multiply: %w", err)
}
if cols != len(v) {
return nil, fmt.Errorf("matrix-vector multiply: matrix has %d columns but vector has %d entries", cols, len(v))
}
out := make([]float64, rows)
for i := 0; i < rows; i++ {
var sum float64
for j := 0; j < cols; j++ {
sum += m[i][j] * v[j]
}
out[i] = sum
}
return out, nil
}
// Dot returns the dot (inner) product of two equal-length vectors.
func Dot(a, b []float64) (float64, error) {
if len(a) != len(b) {
return 0, fmt.Errorf("dot_product: vectors have different lengths (%d vs %d)", len(a), len(b))
}
var sum float64
for i := range a {
sum += a[i] * b[i]
}
return sum, nil
}
// Norm computes the Lp norm of a vector. p=2 is Euclidean (the
// default any ML codebase means when it just says "norm"); p=1 is
// Manhattan; p=math.Inf(1) is the max-absolute-value norm.
func Norm(v []float64, p float64) float64 {
if math.IsInf(p, 1) {
var m float64
for _, x := range v {
if a := math.Abs(x); a > m {
m = a
}
}
return m
}
var sum float64
for _, x := range v {
sum += math.Pow(math.Abs(x), p)
}
return math.Pow(sum, 1/p)
}
// DominantEigen estimates the eigenvalue of largest absolute value
// and its eigenvector via power iteration — the workhorse behind
// things like PageRank and quick PCA-component estimates, and much
// simpler/more numerically robust to implement from scratch than a
// full QR eigendecomposition, which is the right trade-off for a
// hand-rolled numerical toolkit. iterations<=0 defaults to 200, which
// converges for any matrix with a well-separated dominant eigenvalue
// to within float64 precision.
func DominantEigen(m Matrix, iterations int) (eigenvalue float64, eigenvector []float64, err error) {
rows, cols, err := m.Dims()
if err != nil {
return 0, nil, fmt.Errorf("dominant_eigen: %w", err)
}
if rows != cols {
return 0, nil, fmt.Errorf("dominant_eigen: matrix must be square, got %dx%d", rows, cols)
}
if iterations <= 0 {
iterations = 200
}
n := rows
v := make([]float64, n)
for i := range v {
v[i] = 1 / math.Sqrt(float64(n)) // deterministic, reproducible start
}
var lambda float64
for iter := 0; iter < iterations; iter++ {
next, err := MatVecMul(m, v)
if err != nil {
return 0, nil, err
}
norm := Norm(next, 2)
if norm < 1e-15 {
return 0, nil, fmt.Errorf("dominant_eigen: did not converge (matrix may be nilpotent or zero)")
}
for i := range next {
next[i] /= norm
}
v = next
mv, _ := MatVecMul(m, v)
lambda, _ = Dot(v, mv)
}
return lambda, v, nil
}

364
pkg/mathkit/mathkit_test.go Normal file
View File

@@ -0,0 +1,364 @@
package mathkit
import (
"math"
"strings"
"testing"
)
func almostEqual(a, b, tol float64) bool { return math.Abs(a-b) <= tol }
func mustExpr(t *testing.T, src string) *Expr {
t.Helper()
e, err := ParseExpr(src)
if err != nil {
t.Fatalf("ParseExpr(%q): %v", src, err)
}
return e
}
func TestExprEval(t *testing.T) {
cases := []struct {
src string
vars map[string]float64
want float64
}{
{"2 + 3 * 4", nil, 14},
{"(2 + 3) * 4", nil, 20},
{"2 ^ 3 ^ 2", nil, 512}, // right-associative: 2^(3^2) = 2^9
{"-2 ^ 2", nil, -4}, // unary minus binds looser than ^: -(2^2)
{"x * x + 2*x + 1", map[string]float64{"x": 3}, 16},
{"sigmoid(0)", nil, 0.5},
{"relu(-5)", nil, 0},
{"relu(5)", nil, 5},
{"max(3, 7)", nil, 7},
{"sqrt(16)", nil, 4},
{"pi", nil, math.Pi},
}
for _, c := range cases {
e := mustExpr(t, c.src)
got, err := e.Eval(c.vars)
if err != nil {
t.Fatalf("Eval(%q): %v", c.src, err)
}
if !almostEqual(got, c.want, 1e-9) {
t.Errorf("Eval(%q) = %v, want %v", c.src, got, c.want)
}
}
}
func TestExprUnboundVariable(t *testing.T) {
e := mustExpr(t, "x + 1")
if _, err := e.Eval(nil); err == nil {
t.Fatal("expected an error for an unbound variable, got nil")
}
}
func TestMatMul(t *testing.T) {
a := Matrix{{1, 2}, {3, 4}}
b := Matrix{{5, 6}, {7, 8}}
got, err := MatMul(a, b)
if err != nil {
t.Fatal(err)
}
want := Matrix{{19, 22}, {43, 50}}
for i := range want {
for j := range want[i] {
if got[i][j] != want[i][j] {
t.Errorf("MatMul[%d][%d] = %v, want %v", i, j, got[i][j], want[i][j])
}
}
}
}
func TestMatMulShapeMismatch(t *testing.T) {
a := Matrix{{1, 2, 3}}
b := Matrix{{1, 2}}
if _, err := MatMul(a, b); err == nil {
t.Fatal("expected a shape-mismatch error")
}
}
func TestDeterminantAndInverse(t *testing.T) {
a := Matrix{{4, 7}, {2, 6}}
det, err := Determinant(a)
if err != nil {
t.Fatal(err)
}
if !almostEqual(det, 10, 1e-9) {
t.Errorf("det = %v, want 10", det)
}
inv, err := Inverse(a)
if err != nil {
t.Fatal(err)
}
// A * A^-1 should be the identity.
prod, err := MatMul(a, inv)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
for j := 0; j < 2; j++ {
want := 0.0
if i == j {
want = 1
}
if !almostEqual(prod[i][j], want, 1e-9) {
t.Errorf("A*A^-1[%d][%d] = %v, want %v", i, j, prod[i][j], want)
}
}
}
}
func TestInverseSingular(t *testing.T) {
a := Matrix{{1, 2}, {2, 4}} // row 2 = 2 * row 1
if _, err := Inverse(a); err == nil {
t.Fatal("expected a singular-matrix error")
}
}
func TestDominantEigen(t *testing.T) {
// [[2,0],[0,1]] has eigenvalues 2 and 1 — dominant is 2, eigenvector (1,0).
a := Matrix{{2, 0}, {0, 1}}
lambda, v, err := DominantEigen(a, 0)
if err != nil {
t.Fatal(err)
}
if !almostEqual(lambda, 2, 1e-6) {
t.Errorf("eigenvalue = %v, want 2", lambda)
}
if !almostEqual(math.Abs(v[0]), 1, 1e-6) || !almostEqual(v[1], 0, 1e-6) {
t.Errorf("eigenvector = %v, want ~(1,0)", v)
}
}
func TestDerivative(t *testing.T) {
// d/dx[x^2] at x=3 is 6.
e := mustExpr(t, "x^2")
d, err := Derivative(e, "x", map[string]float64{"x": 3}, 0)
if err != nil {
t.Fatal(err)
}
if !almostEqual(d, 6, 1e-4) {
t.Errorf("derivative = %v, want 6", d)
}
}
func TestGradient(t *testing.T) {
// f(x,y) = x^2 + y^2 ; gradient at (3,4) is (6,8).
e := mustExpr(t, "x^2 + y^2")
g, err := Gradient(e, []string{"x", "y"}, map[string]float64{"x": 3, "y": 4}, 0)
if err != nil {
t.Fatal(err)
}
if !almostEqual(g[0], 6, 1e-4) || !almostEqual(g[1], 8, 1e-4) {
t.Errorf("gradient = %v, want [6 8]", g)
}
}
func TestIntegrate(t *testing.T) {
// ∫0..1 x^2 dx = 1/3.
e := mustExpr(t, "x^2")
v, err := Integrate(e, "x", 0, 1, 100, nil)
if err != nil {
t.Fatal(err)
}
if !almostEqual(v, 1.0/3.0, 1e-9) {
t.Errorf("integral = %v, want 1/3", v)
}
}
func TestGradientDescentConvergesToMinimum(t *testing.T) {
// f(x) = (x-3)^2 has its minimum at x=3.
e := mustExpr(t, "(x - 3)^2")
res, err := GradientDescent(e, []string{"x"}, []float64{0}, 0.1, 200, 1e-10)
if err != nil {
t.Fatal(err)
}
final := res.Path[len(res.Path)-1][0]
if !almostEqual(final, 3, 1e-3) {
t.Errorf("gradient descent converged to x=%v, want ~3", final)
}
}
func TestNewtonRootFindsSquareRoot(t *testing.T) {
// Root of x^2 - 2 = 0 is sqrt(2).
e := mustExpr(t, "x^2 - 2")
res, err := NewtonRoot(e, "x", 1, 0, 0, nil)
if err != nil {
t.Fatal(err)
}
root := res.Path[len(res.Path)-1]
if !almostEqual(root, math.Sqrt2, 1e-6) {
t.Errorf("newton root = %v, want %v", root, math.Sqrt2)
}
}
func TestStatistics(t *testing.T) {
data := []float64{2, 4, 4, 4, 5, 5, 7, 9}
mean, _ := Mean(data)
if !almostEqual(mean, 5, 1e-9) {
t.Errorf("mean = %v, want 5", mean)
}
popVar, _ := Variance(data, true)
if !almostEqual(popVar, 4, 1e-9) {
t.Errorf("population variance = %v, want 4", popVar)
}
std, _ := StdDev(data, true)
if !almostEqual(std, 2, 1e-9) {
t.Errorf("population std_dev = %v, want 2", std)
}
}
func TestCorrelationPerfect(t *testing.T) {
x := []float64{1, 2, 3, 4, 5}
y := []float64{2, 4, 6, 8, 10}
c, err := Correlation(x, y)
if err != nil {
t.Fatal(err)
}
if !almostEqual(c, 1, 1e-9) {
t.Errorf("correlation = %v, want 1", c)
}
}
func TestNormalDistribution(t *testing.T) {
// Standard normal at x=0: pdf = 1/sqrt(2*pi), cdf = 0.5.
pdf, _ := NormalPDF(0, 0, 1)
if !almostEqual(pdf, 1/math.Sqrt(2*math.Pi), 1e-9) {
t.Errorf("pdf(0) = %v", pdf)
}
cdf, _ := NormalCDF(0, 0, 1)
if !almostEqual(cdf, 0.5, 1e-9) {
t.Errorf("cdf(0) = %v, want 0.5", cdf)
}
}
func TestSigmoidNumericallyStable(t *testing.T) {
if v := Sigmoid(-1000); v != 0 {
t.Errorf("sigmoid(-1000) = %v, want 0", v)
}
if v := Sigmoid(1000); !almostEqual(v, 1, 1e-12) {
t.Errorf("sigmoid(1000) = %v, want ~1", v)
}
if v := Sigmoid(0); v != 0.5 {
t.Errorf("sigmoid(0) = %v, want 0.5", v)
}
}
func TestSoftmaxSumsToOne(t *testing.T) {
out := Softmax([]float64{1, 2, 3})
var sum float64
for _, v := range out {
sum += v
}
if !almostEqual(sum, 1, 1e-9) {
t.Errorf("softmax sums to %v, want 1", sum)
}
// Largest logit should get the largest probability.
if out[2] <= out[1] || out[1] <= out[0] {
t.Errorf("softmax ordering wrong: %v", out)
}
}
func TestMSELoss(t *testing.T) {
v, err := MSELoss([]float64{1, 2, 3}, []float64{1, 2, 4})
if err != nil {
t.Fatal(err)
}
// errors: 0, 0, -1 -> squared: 0,0,1 -> mean = 1/3
if !almostEqual(v, 1.0/3.0, 1e-9) {
t.Errorf("mse = %v, want 1/3", v)
}
}
func TestLinearRegressionPerfectFit(t *testing.T) {
// y = 2 + 3x, exactly.
X := Matrix{{1}, {2}, {3}, {4}, {5}}
y := []float64{5, 8, 11, 14, 17}
res, err := LinearRegression(X, y)
if err != nil {
t.Fatal(err)
}
if !almostEqual(res.Coefficients[0], 2, 1e-6) {
t.Errorf("intercept = %v, want 2", res.Coefficients[0])
}
if !almostEqual(res.Coefficients[1], 3, 1e-6) {
t.Errorf("slope = %v, want 3", res.Coefficients[1])
}
if !almostEqual(res.RSquared, 1, 1e-6) {
t.Errorf("R^2 = %v, want 1", res.RSquared)
}
}
// --- Solve dispatcher: the JSON-boundary layer the agent tool calls ---
func TestSolveEvaluate(t *testing.T) {
out, err := Solve("evaluate", map[string]any{
"expression": "x^2 + y^2",
"variables": map[string]any{"x": 3.0, "y": 4.0},
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "25") {
t.Errorf("Solve(evaluate) = %q, want it to contain 25", out)
}
}
func TestSolveMatrixMul(t *testing.T) {
out, err := Solve("matrix_mul", map[string]any{
"a": []any{[]any{1.0, 2.0}, []any{3.0, 4.0}},
"b": []any{[]any{5.0, 6.0}, []any{7.0, 8.0}},
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "19") || !strings.Contains(out, "50") {
t.Errorf("Solve(matrix_mul) = %q, missing expected entries", out)
}
}
func TestSolveUnknownOperation(t *testing.T) {
if _, err := Solve("not_a_real_operation", nil); err == nil {
t.Fatal("expected an error for an unknown operation")
}
}
func TestSolveGradientDescent(t *testing.T) {
out, err := Solve("gradient_descent", map[string]any{
"expression": "(x - 3)^2",
"variables": []any{"x"},
"start": []any{0.0},
"learning_rate": 0.1,
"iterations": 200.0,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "converged=true") {
t.Errorf("Solve(gradient_descent) = %q, expected convergence", out)
}
}
func TestSolveMissingField(t *testing.T) {
_, err := Solve("mean", map[string]any{})
if err == nil {
t.Fatal("expected a missing-field error")
}
if !strings.Contains(err.Error(), "data") {
t.Errorf("error %q should mention the missing field", err.Error())
}
}
func TestOperationsListIsSorted(t *testing.T) {
ops := Operations()
if len(ops) == 0 {
t.Fatal("Operations() returned nothing")
}
for i := 1; i < len(ops); i++ {
if ops[i-1] > ops[i] {
t.Fatalf("Operations() not sorted: %q before %q", ops[i-1], ops[i])
}
}
}

191
pkg/mathkit/ml.go Normal file
View File

@@ -0,0 +1,191 @@
package mathkit
import (
"fmt"
"math"
)
// Sigmoid is the logistic function 1 / (1 + e^-x), computed in the
// numerically stable branch-on-sign form so it doesn't overflow
// math.Exp for very negative x (a plain 1/(1+exp(-x)) blows up
// exp(-x) to +Inf for x below about -745 and silently returns 0
// instead of propagating the overflow visibly).
func Sigmoid(x float64) float64 {
if x >= 0 {
z := math.Exp(-x)
return 1 / (1 + z)
}
z := math.Exp(x)
return z / (1 + z)
}
// ReLU is max(0, x).
func ReLU(x float64) float64 {
if x > 0 {
return x
}
return 0
}
// Softmax turns a vector of raw scores ("logits") into a probability
// distribution: non-negative entries that sum to 1. Subtracts the max
// before exponentiating (the standard numerically-stable formulation)
// so large logits don't overflow math.Exp.
func Softmax(x []float64) []float64 {
if len(x) == 0 {
return nil
}
max := x[0]
for _, v := range x[1:] {
if v > max {
max = v
}
}
out := make([]float64, len(x))
var sum float64
for i, v := range x {
e := math.Exp(v - max)
out[i] = e
sum += e
}
for i := range out {
out[i] /= sum
}
return out
}
// MSELoss returns the mean squared error between predictions and
// targets: mean((pred - target)^2), the default regression loss.
func MSELoss(predictions, targets []float64) (float64, error) {
if len(predictions) != len(targets) {
return 0, fmt.Errorf("mse_loss: predictions and targets have different lengths (%d vs %d)", len(predictions), len(targets))
}
if len(predictions) == 0 {
return 0, fmt.Errorf("mse_loss: empty input")
}
var sum float64
for i := range predictions {
d := predictions[i] - targets[i]
sum += d * d
}
return sum / float64(len(predictions)), nil
}
// crossEntropyEps clamps predicted probabilities away from exactly 0
// or 1 before taking a log — ln(0) is -Inf, which would make a single
// confidently-wrong prediction blow up the whole averaged loss to
// +Inf instead of just contributing a large-but-finite penalty. Every
// production cross-entropy implementation (e.g. scikit-learn's
// log_loss) clips for exactly this reason.
const crossEntropyEps = 1e-12
// CrossEntropyLoss returns the mean binary cross-entropy between
// predicted probabilities and 0/1 (or soft, in [0,1]) targets:
// mean(-(y*ln(p) + (1-y)*ln(1-p))), the default classification loss.
func CrossEntropyLoss(predictions, targets []float64) (float64, error) {
if len(predictions) != len(targets) {
return 0, fmt.Errorf("cross_entropy_loss: predictions and targets have different lengths (%d vs %d)", len(predictions), len(targets))
}
if len(predictions) == 0 {
return 0, fmt.Errorf("cross_entropy_loss: empty input")
}
var sum float64
for i, p := range predictions {
if p < 0 || p > 1 {
return 0, fmt.Errorf("cross_entropy_loss: predictions must be probabilities in [0, 1], got %g at index %d", p, i)
}
p = math.Min(math.Max(p, crossEntropyEps), 1-crossEntropyEps)
y := targets[i]
sum += -(y*math.Log(p) + (1-y)*math.Log(1-p))
}
return sum / float64(len(predictions)), nil
}
// LinearRegressionResult is a fitted ordinary-least-squares linear
// model.
type LinearRegressionResult struct {
// Coefficients[0] is the intercept, Coefficients[1:] line up with
// the feature columns of X in order.
Coefficients []float64
Predictions []float64
Residuals []float64
RSquared float64
}
// LinearRegression fits y ≈ X·β + intercept by ordinary least squares
// via the normal equations β = (XᵀX)^-1 Xᵀy, with an intercept column
// of 1s prepended automatically (the caller supplies only the real
// feature columns in X). The normal-equation approach is the direct,
// closed-form solution to minimizing squared error — the textbook
// ML-101 regression fit, as opposed to reaching it iteratively via
// GradientDescent on the MSE loss.
func LinearRegression(X Matrix, y []float64) (*LinearRegressionResult, error) {
rows, cols, err := X.Dims()
if err != nil {
return nil, fmt.Errorf("linear_regression: %w", err)
}
if rows != len(y) {
return nil, fmt.Errorf("linear_regression: X has %d rows but y has %d entries", rows, len(y))
}
if rows <= cols {
return nil, fmt.Errorf("linear_regression: need more observations (%d) than features+1 (%d) to fit uniquely", rows, cols+1)
}
// Prepend the intercept column of 1s.
design := make(Matrix, rows)
for i := 0; i < rows; i++ {
row := make([]float64, cols+1)
row[0] = 1
copy(row[1:], X[i])
design[i] = row
}
designT, err := Transpose(design)
if err != nil {
return nil, fmt.Errorf("linear_regression: %w", err)
}
xtx, err := MatMul(designT, design)
if err != nil {
return nil, fmt.Errorf("linear_regression: %w", err)
}
xtxInv, err := Inverse(xtx)
if err != nil {
return nil, fmt.Errorf("linear_regression: features are collinear (XᵀX is singular): %w", err)
}
xty, err := MatVecMul(designT, y)
if err != nil {
return nil, fmt.Errorf("linear_regression: %w", err)
}
beta, err := MatVecMul(xtxInv, xty)
if err != nil {
return nil, fmt.Errorf("linear_regression: %w", err)
}
preds, err := MatVecMul(design, beta)
if err != nil {
return nil, fmt.Errorf("linear_regression: %w", err)
}
residuals := make([]float64, rows)
var ssRes float64
for i := range preds {
residuals[i] = y[i] - preds[i]
ssRes += residuals[i] * residuals[i]
}
meanY, _ := Mean(y)
var ssTot float64
for _, v := range y {
d := v - meanY
ssTot += d * d
}
rSquared := 1.0
if ssTot > 0 {
rSquared = 1 - ssRes/ssTot
}
return &LinearRegressionResult{
Coefficients: beta,
Predictions: preds,
Residuals: residuals,
RSquared: rSquared,
}, nil
}

135
pkg/mathkit/optimize.go Normal file
View File

@@ -0,0 +1,135 @@
package mathkit
import (
"fmt"
"math"
)
// GradientDescentResult is the trace of one gradient-descent run.
type GradientDescentResult struct {
// Path holds the point visited at the start of every iteration,
// Path[0] being the caller's starting point and the last entry
// being the final point — so len(Path) == Iterations+1. Kept in
// full (rather than just the final point) because seeing the
// trajectory is usually the point of running this: it's how you
// tell "converging nicely" apart from "diverging" or
// "oscillating because the learning rate is too high".
Path [][]float64
// Values holds f(Path[i]) for each point in Path, same length.
Values []float64
// Converged is true if the gradient norm dropped below the
// requested tolerance before Iterations ran out.
Converged bool
}
// GradientDescent runs plain (batch) gradient descent on expr, over
// the variables listed (in order — that order defines the vector
// space the descent happens in), starting at `start`, for at most
// `iterations` steps of size `learningRate`. tol<=0 disables the
// early-stop check (it always runs the full iteration count).
//
// This is deliberately vanilla gradient descent — no momentum, no
// Adam-style adaptive rates — because the point of this tool is to
// let a user see and reason about the bare update rule
// x := x - lr * ∇f(x) that every fancier optimizer builds on, not to
// re-implement a training framework.
func GradientDescent(expr *Expr, variables []string, start []float64, learningRate float64, iterations int, tol float64) (*GradientDescentResult, error) {
if len(variables) != len(start) {
return nil, fmt.Errorf("gradient_descent: %d variable name(s) but start point has %d coordinate(s)", len(variables), len(start))
}
if learningRate <= 0 {
return nil, fmt.Errorf("gradient_descent: learning_rate must be positive")
}
if iterations <= 0 {
iterations = 100
}
point := append([]float64(nil), start...)
res := &GradientDescentResult{}
addPoint := func(p []float64) error {
vars := make(map[string]float64, len(variables))
for i, name := range variables {
vars[name] = p[i]
}
v, err := expr.Eval(vars)
if err != nil {
return err
}
res.Path = append(res.Path, append([]float64(nil), p...))
res.Values = append(res.Values, v)
return nil
}
if err := addPoint(point); err != nil {
return nil, fmt.Errorf("gradient_descent: evaluating start point: %w", err)
}
vars := make(map[string]float64, len(variables))
for iter := 0; iter < iterations; iter++ {
for i, name := range variables {
vars[name] = point[i]
}
grad, err := Gradient(expr, variables, vars, 0)
if err != nil {
return nil, fmt.Errorf("gradient_descent: iteration %d: %w", iter, err)
}
if tol > 0 && Norm(grad, 2) < tol {
res.Converged = true
break
}
for i := range point {
point[i] -= learningRate * grad[i]
}
if err := addPoint(point); err != nil {
return nil, fmt.Errorf("gradient_descent: iteration %d: %w", iter, err)
}
if math.IsNaN(point[0]) || math.IsInf(point[0], 0) {
return nil, fmt.Errorf("gradient_descent: diverged (values went to NaN/Inf) — try a smaller learning_rate")
}
}
return res, nil
}
// NewtonRootResult is the trace of one Newton's-method root-finding
// run on a single-variable expression.
type NewtonRootResult struct {
Path []float64 // x_0, x_1, ..., x_final
Converged bool
}
// NewtonRoot finds a root of expr(variable) = 0 via Newton's method:
// x_{n+1} = x_n - f(x_n)/f'(x_n), with f' estimated by central
// difference (see Derivative). Any other free variables in expr must
// be supplied in `fixed`. tol<=0 defaults to 1e-10.
func NewtonRoot(expr *Expr, variable string, start float64, iterations int, tol float64, fixed map[string]float64) (*NewtonRootResult, error) {
if iterations <= 0 {
iterations = 100
}
if tol <= 0 {
tol = 1e-10
}
x := start
res := &NewtonRootResult{Path: []float64{x}}
vars := cloneVars(fixed)
for iter := 0; iter < iterations; iter++ {
vars[variable] = x
fx, err := expr.Eval(vars)
if err != nil {
return nil, fmt.Errorf("newton_root: iteration %d: %w", iter, err)
}
if math.Abs(fx) < tol {
res.Converged = true
break
}
dfx, err := Derivative(expr, variable, vars, 0)
if err != nil {
return nil, fmt.Errorf("newton_root: iteration %d: %w", iter, err)
}
if math.Abs(dfx) < 1e-14 {
return nil, fmt.Errorf("newton_root: derivative vanished near x=%g (flat spot) — try a different start", x)
}
x = x - fx/dfx
res.Path = append(res.Path, x)
if math.IsNaN(x) || math.IsInf(x, 0) {
return nil, fmt.Errorf("newton_root: diverged (x went to NaN/Inf) — try a different start")
}
}
return res, nil
}

835
pkg/mathkit/solve.go Normal file
View File

@@ -0,0 +1,835 @@
package mathkit
import (
"fmt"
"math"
"sort"
"strings"
)
// Solve dispatches a math problem by string operation name against a
// loosely-typed params map — exactly the shape a JSON object (an LLM
// tool-call argument blob, decoded with encoding/json into
// map[string]any) takes once decoded. It exists as one layer above
// the typed functions in linalg.go/calculus.go/statistics.go/
// optimize.go/ml.go specifically so a caller at the JSON boundary
// (services/agent/internal/infrastructure/toolexec/mlmath.go) has one
// function to call and one error type to handle, instead of a type
// switch per operation.
//
// Returns a human-readable report (not raw JSON or a bare float) on
// success, because the caller here is ultimately an LLM deciding what
// to do next — a labeled, explained answer is directly usable in its
// response, where a bare number would have to be re-explained.
func Solve(operation string, params map[string]any) (string, error) {
op := strings.ToLower(strings.TrimSpace(operation))
switch op {
// --- general ---
case "evaluate":
return solveEvaluate(params)
// --- linear algebra ---
case "matrix_add", "matrix_sub", "matrix_mul":
return solveMatrixBinOp(op, params)
case "matrix_transpose":
return solveMatrixTranspose(params)
case "matrix_determinant":
return solveMatrixDeterminant(params)
case "matrix_inverse":
return solveMatrixInverse(params)
case "matrix_rank":
return solveMatrixRank(params)
case "matrix_trace":
return solveMatrixTrace(params)
case "dot_product":
return solveDotProduct(params)
case "vector_norm":
return solveVectorNorm(params)
case "dominant_eigen":
return solveDominantEigen(params)
// --- calculus ---
case "derivative":
return solveDerivative(params)
case "gradient":
return solveGradient(params)
case "integral":
return solveIntegral(params)
// --- optimization ---
case "gradient_descent":
return solveGradientDescent(params)
case "newton_root":
return solveNewtonRoot(params)
// --- statistics & probability ---
case "mean":
return solveReduce1(params, "mean", func(d []float64) (float64, error) { return Mean(d) })
case "variance":
return solveReduce1Pop(params, "variance", Variance)
case "std_dev":
return solveReduce1Pop(params, "std_dev", StdDev)
case "median":
return solveReduce1(params, "median", Median)
case "covariance":
return solveReduce2Pop(params, "covariance", Covariance)
case "correlation":
return solveReduce2(params, "correlation", Correlation)
case "z_score_normalize":
return solveNormalize(params, "z_score_normalize", ZScoreNormalize)
case "min_max_normalize":
return solveNormalize(params, "min_max_normalize", MinMaxNormalize)
case "normal_pdf":
return solveNormalDist(params, "normal_pdf", NormalPDF)
case "normal_cdf":
return solveNormalDist(params, "normal_cdf", NormalCDF)
case "binomial_pmf":
return solveBinomialPMF(params)
// --- ML primitives ---
case "sigmoid":
return solveElementwise(params, "sigmoid", Sigmoid)
case "relu":
return solveElementwise(params, "relu", ReLU)
case "tanh":
return solveElementwise(params, "tanh", math.Tanh)
case "softmax":
return solveSoftmax(params)
case "mse_loss":
return solveLoss(params, "mse_loss", MSELoss)
case "cross_entropy_loss":
return solveLoss(params, "cross_entropy_loss", CrossEntropyLoss)
case "linear_regression":
return solveLinearRegression(params)
default:
return "", fmt.Errorf("unknown operation %q — see the tool description for the full list of supported operations", operation)
}
}
// ---------- param extraction helpers ----------
//
// Every helper below reports a specific, actionable error (which
// field was wrong and why) rather than a generic type-assertion
// panic recovery message, because these errors are fed straight back
// to the calling model as the tool result — see toolexec's rpcFail /
// IsError convention — and "field X must be a number, got a string"
// is something the model can act on; "interface conversion failed"
// is not.
func reqFloat(params map[string]any, key string) (float64, error) {
v, ok := params[key]
if !ok {
return 0, fmt.Errorf("missing required field %q", key)
}
f, ok := toFloat(v)
if !ok {
return 0, fmt.Errorf("field %q must be a number", key)
}
return f, nil
}
func optFloat(params map[string]any, key string, fallback float64) float64 {
v, ok := params[key]
if !ok {
return fallback
}
if f, ok := toFloat(v); ok {
return f
}
return fallback
}
func optInt(params map[string]any, key string, fallback int) int {
v, ok := params[key]
if !ok {
return fallback
}
if f, ok := toFloat(v); ok {
return int(f)
}
return fallback
}
func optBool(params map[string]any, key string, fallback bool) bool {
v, ok := params[key]
if !ok {
return fallback
}
if b, ok := v.(bool); ok {
return b
}
return fallback
}
func toFloat(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case int:
return float64(n), true
case int64:
return float64(n), true
}
return 0, false
}
func reqString(params map[string]any, key string) (string, error) {
v, ok := params[key]
if !ok {
return "", fmt.Errorf("missing required field %q", key)
}
s, ok := v.(string)
if !ok || strings.TrimSpace(s) == "" {
return "", fmt.Errorf("field %q must be a non-empty string", key)
}
return s, nil
}
func optString(params map[string]any, key, fallback string) string {
v, ok := params[key]
if !ok {
return fallback
}
s, ok := v.(string)
if !ok {
return fallback
}
return s
}
func reqFloatSlice(params map[string]any, key string) ([]float64, error) {
v, ok := params[key]
if !ok {
return nil, fmt.Errorf("missing required field %q (expected an array of numbers)", key)
}
raw, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("field %q must be an array of numbers", key)
}
out := make([]float64, len(raw))
for i, x := range raw {
f, ok := toFloat(x)
if !ok {
return nil, fmt.Errorf("field %q: element %d is not a number", key, i)
}
out[i] = f
}
return out, nil
}
func reqStringSlice(params map[string]any, key string) ([]string, error) {
v, ok := params[key]
if !ok {
return nil, fmt.Errorf("missing required field %q (expected an array of strings)", key)
}
raw, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("field %q must be an array of strings", key)
}
out := make([]string, len(raw))
for i, x := range raw {
s, ok := x.(string)
if !ok {
return nil, fmt.Errorf("field %q: element %d is not a string", key, i)
}
out[i] = s
}
return out, nil
}
func reqMatrix(params map[string]any, key string) (Matrix, error) {
v, ok := params[key]
if !ok {
return nil, fmt.Errorf("missing required field %q (expected a 2D array of numbers)", key)
}
rows, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("field %q must be a 2D array of numbers, e.g. [[1,2],[3,4]]", key)
}
m := make(Matrix, len(rows))
for i, r := range rows {
rowRaw, ok := r.([]any)
if !ok {
return nil, fmt.Errorf("field %q: row %d is not an array", key, i)
}
row := make([]float64, len(rowRaw))
for j, x := range rowRaw {
f, ok := toFloat(x)
if !ok {
return nil, fmt.Errorf("field %q: row %d, column %d is not a number", key, i, j)
}
row[j] = f
}
m[i] = row
}
return m, nil
}
// varMap builds the variable bindings an expression needs from an
// "at" (or "variables") object field — {"x": 1, "y": 2} — falling
// back to an empty map if the field is absent (a single-variable
// expression evaluated via the "variable"/"at" pair still works).
func varMap(params map[string]any, key string) (map[string]float64, error) {
v, ok := params[key]
if !ok {
return map[string]float64{}, nil
}
raw, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("field %q must be an object mapping variable names to numbers, e.g. {\"x\": 1.5}", key)
}
out := make(map[string]float64, len(raw))
for k, x := range raw {
f, ok := toFloat(x)
if !ok {
return nil, fmt.Errorf("field %q: value for %q is not a number", key, k)
}
out[k] = f
}
return out, nil
}
func parseExprField(params map[string]any) (*Expr, error) {
src, err := reqString(params, "expression")
if err != nil {
return nil, err
}
expr, err := ParseExpr(src)
if err != nil {
return nil, err
}
return expr, nil
}
func fmtFloat(f float64) string {
if math.IsNaN(f) {
return "NaN"
}
if math.IsInf(f, 1) {
return "+Inf"
}
if math.IsInf(f, -1) {
return "-Inf"
}
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.10f", f), "0"), ".")
}
func fmtFloats(fs []float64) string {
parts := make([]string, len(fs))
for i, f := range fs {
parts[i] = fmtFloat(f)
}
return "[" + strings.Join(parts, ", ") + "]"
}
func fmtMatrix(m Matrix) string {
var sb strings.Builder
for _, row := range m {
sb.WriteString(fmtFloats(row))
sb.WriteByte('\n')
}
return strings.TrimRight(sb.String(), "\n")
}
// ---------- operation implementations ----------
func solveEvaluate(params map[string]any) (string, error) {
expr, err := parseExprField(params)
if err != nil {
return "", err
}
vars, err := varMap(params, "variables")
if err != nil {
return "", err
}
result, err := expr.Eval(vars)
if err != nil {
return "", err
}
return fmt.Sprintf("%s = %s", expr.String(), fmtFloat(result)), nil
}
func solveMatrixBinOp(op string, params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
b, err := reqMatrix(params, "b")
if err != nil {
return "", err
}
var out Matrix
switch op {
case "matrix_add":
out, err = MatAdd(a, b)
case "matrix_sub":
out, err = MatSub(a, b)
case "matrix_mul":
out, err = MatMul(a, b)
}
if err != nil {
return "", err
}
return fmt.Sprintf("%s result:\n%s", op, fmtMatrix(out)), nil
}
func solveMatrixTranspose(params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
out, err := Transpose(a)
if err != nil {
return "", err
}
return fmt.Sprintf("transpose result:\n%s", fmtMatrix(out)), nil
}
func solveMatrixDeterminant(params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
det, err := Determinant(a)
if err != nil {
return "", err
}
return fmt.Sprintf("det(A) = %s", fmtFloat(det)), nil
}
func solveMatrixInverse(params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
inv, err := Inverse(a)
if err != nil {
return "", err
}
return fmt.Sprintf("A^-1:\n%s", fmtMatrix(inv)), nil
}
func solveMatrixRank(params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
r, err := Rank(a)
if err != nil {
return "", err
}
return fmt.Sprintf("rank(A) = %d", r), nil
}
func solveMatrixTrace(params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
tr, err := Trace(a)
if err != nil {
return "", err
}
return fmt.Sprintf("trace(A) = %s", fmtFloat(tr)), nil
}
func solveDotProduct(params map[string]any) (string, error) {
a, err := reqFloatSlice(params, "a")
if err != nil {
return "", err
}
b, err := reqFloatSlice(params, "b")
if err != nil {
return "", err
}
d, err := Dot(a, b)
if err != nil {
return "", err
}
return fmt.Sprintf("a · b = %s", fmtFloat(d)), nil
}
func solveVectorNorm(params map[string]any) (string, error) {
a, err := reqFloatSlice(params, "a")
if err != nil {
return "", err
}
p := optFloat(params, "p", 2)
n := Norm(a, p)
label := fmt.Sprintf("L%g", p)
if math.IsInf(p, 1) {
label = "L∞"
}
return fmt.Sprintf("%s norm = %s", label, fmtFloat(n)), nil
}
func solveDominantEigen(params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
iters := optInt(params, "iterations", 0)
lambda, v, err := DominantEigen(a, iters)
if err != nil {
return "", err
}
return fmt.Sprintf("dominant eigenvalue ≈ %s\neigenvector ≈ %s", fmtFloat(lambda), fmtFloats(v)), nil
}
func solveDerivative(params map[string]any) (string, error) {
expr, err := parseExprField(params)
if err != nil {
return "", err
}
variable, err := reqString(params, "variable")
if err != nil {
return "", err
}
at, err := varMap(params, "at")
if err != nil {
return "", err
}
if _, ok := at[variable]; !ok {
at[variable] = optFloat(params, "value", 0)
}
h := optFloat(params, "h", 0)
d, err := Derivative(expr, variable, at, h)
if err != nil {
return "", err
}
return fmt.Sprintf("d/d%s[%s] at %s=%s ≈ %s", variable, expr.String(), variable, fmtFloat(at[variable]), fmtFloat(d)), nil
}
func solveGradient(params map[string]any) (string, error) {
expr, err := parseExprField(params)
if err != nil {
return "", err
}
variables, err := reqStringSlice(params, "variables")
if err != nil {
return "", err
}
at, err := varMap(params, "at")
if err != nil {
return "", err
}
h := optFloat(params, "h", 0)
grad, err := Gradient(expr, variables, at, h)
if err != nil {
return "", err
}
return fmt.Sprintf("∇[%s] with respect to %s ≈ %s", expr.String(), strings.Join(variables, ","), fmtFloats(grad)), nil
}
func solveIntegral(params map[string]any) (string, error) {
expr, err := parseExprField(params)
if err != nil {
return "", err
}
variable, err := reqString(params, "variable")
if err != nil {
return "", err
}
from, err := reqFloat(params, "from")
if err != nil {
return "", err
}
to, err := reqFloat(params, "to")
if err != nil {
return "", err
}
steps := optInt(params, "steps", 0)
fixed, err := varMap(params, "variables")
if err != nil {
return "", err
}
result, err := Integrate(expr, variable, from, to, steps, fixed)
if err != nil {
return "", err
}
return fmt.Sprintf("∫[%s to %s] %s d%s ≈ %s", fmtFloat(from), fmtFloat(to), expr.String(), variable, fmtFloat(result)), nil
}
func solveGradientDescent(params map[string]any) (string, error) {
expr, err := parseExprField(params)
if err != nil {
return "", err
}
variables, err := reqStringSlice(params, "variables")
if err != nil {
return "", err
}
start, err := reqFloatSlice(params, "start")
if err != nil {
return "", err
}
lr, err := reqFloat(params, "learning_rate")
if err != nil {
return "", err
}
iterations := optInt(params, "iterations", 100)
tol := optFloat(params, "tolerance", 1e-8)
res, err := GradientDescent(expr, variables, start, lr, iterations, tol)
if err != nil {
return "", err
}
last := len(res.Path) - 1
var sb strings.Builder
fmt.Fprintf(&sb, "gradient descent on f(%s) = %s\n", strings.Join(variables, ","), expr.String())
fmt.Fprintf(&sb, "start: %s -> f = %s\n", fmtFloats(res.Path[0]), fmtFloat(res.Values[0]))
fmt.Fprintf(&sb, "ran %d step(s), converged=%v\n", last, res.Converged)
fmt.Fprintf(&sb, "final: %s -> f = %s\n", fmtFloats(res.Path[last]), fmtFloat(res.Values[last]))
// Show a short trajectory sample so the model can see the descent
// shape without a huge wall of numbers for large iteration counts.
sampleN := 5
if last+1 <= sampleN*2 {
for i := 0; i <= last; i++ {
fmt.Fprintf(&sb, " step %d: x=%s f=%s\n", i, fmtFloats(res.Path[i]), fmtFloat(res.Values[i]))
}
} else {
for i := 0; i < sampleN; i++ {
fmt.Fprintf(&sb, " step %d: x=%s f=%s\n", i, fmtFloats(res.Path[i]), fmtFloat(res.Values[i]))
}
sb.WriteString(" ...\n")
for i := last - sampleN + 1; i <= last; i++ {
fmt.Fprintf(&sb, " step %d: x=%s f=%s\n", i, fmtFloats(res.Path[i]), fmtFloat(res.Values[i]))
}
}
return strings.TrimRight(sb.String(), "\n"), nil
}
func solveNewtonRoot(params map[string]any) (string, error) {
expr, err := parseExprField(params)
if err != nil {
return "", err
}
variable, err := reqString(params, "variable")
if err != nil {
return "", err
}
start, err := reqFloat(params, "start")
if err != nil {
return "", err
}
iterations := optInt(params, "iterations", 0)
tol := optFloat(params, "tolerance", 0)
fixed, err := varMap(params, "variables")
if err != nil {
return "", err
}
res, err := NewtonRoot(expr, variable, start, iterations, tol, fixed)
if err != nil {
return "", err
}
last := res.Path[len(res.Path)-1]
return fmt.Sprintf("newton's method on %s(%s)=0: converged=%v after %d step(s), root ≈ %s=%s",
expr.String(), variable, res.Converged, len(res.Path)-1, variable, fmtFloat(last)), nil
}
func solveReduce1(params map[string]any, name string, f func([]float64) (float64, error)) (string, error) {
data, err := reqFloatSlice(params, "data")
if err != nil {
return "", err
}
v, err := f(data)
if err != nil {
return "", err
}
return fmt.Sprintf("%s(%s) = %s", name, fmtFloats(data), fmtFloat(v)), nil
}
func solveReduce1Pop(params map[string]any, name string, f func([]float64, bool) (float64, error)) (string, error) {
data, err := reqFloatSlice(params, "data")
if err != nil {
return "", err
}
population := optBool(params, "population", false)
v, err := f(data, population)
if err != nil {
return "", err
}
kind := "sample"
if population {
kind = "population"
}
return fmt.Sprintf("%s %s(%s) = %s", kind, name, fmtFloats(data), fmtFloat(v)), nil
}
func solveReduce2(params map[string]any, name string, f func([]float64, []float64) (float64, error)) (string, error) {
x, err := reqFloatSlice(params, "x")
if err != nil {
return "", err
}
y, err := reqFloatSlice(params, "y")
if err != nil {
return "", err
}
v, err := f(x, y)
if err != nil {
return "", err
}
return fmt.Sprintf("%s(x, y) = %s", name, fmtFloat(v)), nil
}
func solveReduce2Pop(params map[string]any, name string, f func([]float64, []float64, bool) (float64, error)) (string, error) {
x, err := reqFloatSlice(params, "x")
if err != nil {
return "", err
}
y, err := reqFloatSlice(params, "y")
if err != nil {
return "", err
}
population := optBool(params, "population", false)
v, err := f(x, y, population)
if err != nil {
return "", err
}
kind := "sample"
if population {
kind = "population"
}
return fmt.Sprintf("%s %s(x, y) = %s", kind, name, fmtFloat(v)), nil
}
func solveNormalize(params map[string]any, name string, f func([]float64) ([]float64, error)) (string, error) {
data, err := reqFloatSlice(params, "data")
if err != nil {
return "", err
}
out, err := f(data)
if err != nil {
return "", err
}
return fmt.Sprintf("%s(%s) = %s", name, fmtFloats(data), fmtFloats(out)), nil
}
func solveNormalDist(params map[string]any, name string, f func(float64, float64, float64) (float64, error)) (string, error) {
x, err := reqFloat(params, "x")
if err != nil {
return "", err
}
mean := optFloat(params, "mean", 0)
std := optFloat(params, "std_dev", 1)
v, err := f(x, mean, std)
if err != nil {
return "", err
}
return fmt.Sprintf("%s(x=%s, mean=%s, std_dev=%s) = %s", name, fmtFloat(x), fmtFloat(mean), fmtFloat(std), fmtFloat(v)), nil
}
func solveBinomialPMF(params map[string]any) (string, error) {
k := optInt(params, "k", -1)
if k < 0 {
return "", fmt.Errorf("missing required field %q", "k")
}
n := optInt(params, "n", -1)
if n < 0 {
return "", fmt.Errorf("missing required field %q", "n")
}
p, err := reqFloat(params, "p")
if err != nil {
return "", err
}
v, err := BinomialPMF(k, n, p)
if err != nil {
return "", err
}
return fmt.Sprintf("P(X=%d) for X~Binomial(n=%d, p=%s) = %s", k, n, fmtFloat(p), fmtFloat(v)), nil
}
func solveElementwise(params map[string]any, name string, f func(float64) float64) (string, error) {
if raw, ok := params["x"].([]any); ok {
data := make([]float64, len(raw))
for i, v := range raw {
fv, ok := toFloat(v)
if !ok {
return "", fmt.Errorf("field \"x\": element %d is not a number", i)
}
data[i] = fv
}
out := make([]float64, len(data))
for i, v := range data {
out[i] = f(v)
}
return fmt.Sprintf("%s(%s) = %s", name, fmtFloats(data), fmtFloats(out)), nil
}
x, err := reqFloat(params, "x")
if err != nil {
return "", fmt.Errorf(`field "x" must be a number or an array of numbers: %w`, err)
}
return fmt.Sprintf("%s(%s) = %s", name, fmtFloat(x), fmtFloat(f(x))), nil
}
func solveSoftmax(params map[string]any) (string, error) {
x, err := reqFloatSlice(params, "x")
if err != nil {
return "", err
}
out := Softmax(x)
sum := 0.0
for _, v := range out {
sum += v
}
return fmt.Sprintf("softmax(%s) = %s (sums to %s)", fmtFloats(x), fmtFloats(out), fmtFloat(sum)), nil
}
func solveLoss(params map[string]any, name string, f func([]float64, []float64) (float64, error)) (string, error) {
pred, err := reqFloatSlice(params, "predictions")
if err != nil {
return "", err
}
target, err := reqFloatSlice(params, "targets")
if err != nil {
return "", err
}
v, err := f(pred, target)
if err != nil {
return "", err
}
return fmt.Sprintf("%s(predictions, targets) = %s", name, fmtFloat(v)), nil
}
func solveLinearRegression(params map[string]any) (string, error) {
X, err := reqMatrix(params, "x")
if err != nil {
return "", err
}
y, err := reqFloatSlice(params, "y")
if err != nil {
return "", err
}
res, err := LinearRegression(X, y)
if err != nil {
return "", err
}
var sb strings.Builder
fmt.Fprintf(&sb, "ordinary least squares fit: y ≈ %s", fmtFloat(res.Coefficients[0]))
for i, c := range res.Coefficients[1:] {
fmt.Fprintf(&sb, " + %s*x%d", fmtFloat(c), i+1)
}
fmt.Fprintf(&sb, "\nR² = %s\n", fmtFloat(res.RSquared))
fmt.Fprintf(&sb, "predictions: %s\n", fmtFloats(res.Predictions))
fmt.Fprintf(&sb, "residuals: %s", fmtFloats(res.Residuals))
return sb.String(), nil
}
// Operations returns the sorted list of every operation name Solve
// accepts — used by the agent's tool-catalog description so the
// supported operation list is generated from the real dispatch table
// instead of hand-copied prose that can drift out of sync with it.
func Operations() []string {
ops := []string{
"evaluate",
"matrix_add", "matrix_sub", "matrix_mul", "matrix_transpose", "matrix_determinant",
"matrix_inverse", "matrix_rank", "matrix_trace", "dot_product", "vector_norm", "dominant_eigen",
"derivative", "gradient", "integral",
"gradient_descent", "newton_root",
"mean", "variance", "std_dev", "median", "covariance", "correlation",
"z_score_normalize", "min_max_normalize", "normal_pdf", "normal_cdf", "binomial_pmf",
"sigmoid", "relu", "tanh", "softmax", "mse_loss", "cross_entropy_loss", "linear_regression",
}
sort.Strings(ops)
return ops
}

224
pkg/mathkit/statistics.go Normal file
View File

@@ -0,0 +1,224 @@
package mathkit
import (
"fmt"
"math"
)
// Mean returns the arithmetic mean of data.
func Mean(data []float64) (float64, error) {
if len(data) == 0 {
return 0, fmt.Errorf("mean: empty data set")
}
var sum float64
for _, v := range data {
sum += v
}
return sum / float64(len(data)), nil
}
// Variance returns the variance of data. population=true divides by
// n (the population variance); population=false (the default for ML
// work, where `data` is almost always a sample) divides by n-1
// (Bessel's correction, the unbiased sample-variance estimator).
func Variance(data []float64, population bool) (float64, error) {
n := len(data)
if n == 0 {
return 0, fmt.Errorf("variance: empty data set")
}
if !population && n < 2 {
return 0, fmt.Errorf("variance: sample variance needs at least 2 points (use population variance for a single point)")
}
mean, _ := Mean(data)
var ss float64
for _, v := range data {
d := v - mean
ss += d * d
}
denom := float64(n)
if !population {
denom = float64(n - 1)
}
return ss / denom, nil
}
// StdDev returns the standard deviation (sqrt of Variance).
func StdDev(data []float64, population bool) (float64, error) {
v, err := Variance(data, population)
if err != nil {
return 0, err
}
return math.Sqrt(v), nil
}
// Median returns the median of data (average of the two middle
// values for an even-length slice). Does not mutate the input.
func Median(data []float64) (float64, error) {
if len(data) == 0 {
return 0, fmt.Errorf("median: empty data set")
}
sorted := append([]float64(nil), data...)
insertionSort(sorted)
n := len(sorted)
if n%2 == 1 {
return sorted[n/2], nil
}
return (sorted[n/2-1] + sorted[n/2]) / 2, nil
}
// insertionSort is O(n^2) but this package only ever sorts small
// stats samples, where it beats sort.Float64s' overhead and keeps
// the package allocation-free apart from the clone itself.
func insertionSort(a []float64) {
for i := 1; i < len(a); i++ {
key := a[i]
j := i - 1
for j >= 0 && a[j] > key {
a[j+1] = a[j]
j--
}
a[j+1] = key
}
}
// Covariance returns the sample (or population) covariance of two
// equal-length data sets — the building block of both Correlation and
// a PCA covariance matrix.
func Covariance(x, y []float64, population bool) (float64, error) {
if len(x) != len(y) {
return 0, fmt.Errorf("covariance: data sets have different lengths (%d vs %d)", len(x), len(y))
}
n := len(x)
if n == 0 {
return 0, fmt.Errorf("covariance: empty data set")
}
if !population && n < 2 {
return 0, fmt.Errorf("covariance: sample covariance needs at least 2 points")
}
mx, _ := Mean(x)
my, _ := Mean(y)
var sum float64
for i := 0; i < n; i++ {
sum += (x[i] - mx) * (y[i] - my)
}
denom := float64(n)
if !population {
denom = float64(n - 1)
}
return sum / denom, nil
}
// Correlation returns the Pearson correlation coefficient of x and y,
// in [-1, 1].
func Correlation(x, y []float64) (float64, error) {
cov, err := Covariance(x, y, true)
if err != nil {
return 0, fmt.Errorf("correlation: %w", err)
}
sx, err := StdDev(x, true)
if err != nil {
return 0, fmt.Errorf("correlation: %w", err)
}
sy, err := StdDev(y, true)
if err != nil {
return 0, fmt.Errorf("correlation: %w", err)
}
if sx == 0 || sy == 0 {
return 0, fmt.Errorf("correlation: a constant data set has undefined correlation")
}
return cov / (sx * sy), nil
}
// ZScoreNormalize returns (x - mean) / stddev for each point — the
// standardization every ML feature-scaling pipeline applies before
// training a distance- or gradient-sensitive model.
func ZScoreNormalize(data []float64) ([]float64, error) {
mean, err := Mean(data)
if err != nil {
return nil, fmt.Errorf("z_score_normalize: %w", err)
}
std, err := StdDev(data, true)
if err != nil {
return nil, fmt.Errorf("z_score_normalize: %w", err)
}
if std == 0 {
return nil, fmt.Errorf("z_score_normalize: a constant data set has zero standard deviation")
}
out := make([]float64, len(data))
for i, v := range data {
out[i] = (v - mean) / std
}
return out, nil
}
// MinMaxNormalize rescales data to [0, 1].
func MinMaxNormalize(data []float64) ([]float64, error) {
if len(data) == 0 {
return nil, fmt.Errorf("min_max_normalize: empty data set")
}
lo, hi := data[0], data[0]
for _, v := range data {
if v < lo {
lo = v
}
if v > hi {
hi = v
}
}
if hi == lo {
return nil, fmt.Errorf("min_max_normalize: a constant data set has zero range")
}
out := make([]float64, len(data))
for i, v := range data {
out[i] = (v - lo) / (hi - lo)
}
return out, nil
}
// NormalPDF is the probability density function of N(mean, stdDev^2)
// at x.
func NormalPDF(x, mean, stdDev float64) (float64, error) {
if stdDev <= 0 {
return 0, fmt.Errorf("normal_pdf: std_dev must be positive")
}
z := (x - mean) / stdDev
return math.Exp(-0.5*z*z) / (stdDev * math.Sqrt(2*math.Pi)), nil
}
// NormalCDF is the cumulative distribution function of N(mean,
// stdDev^2) at x, computed via math.Erf (the standard closed form:
// Φ(x) = (1 + erf((x-μ)/(σ√2))) / 2).
func NormalCDF(x, mean, stdDev float64) (float64, error) {
if stdDev <= 0 {
return 0, fmt.Errorf("normal_cdf: std_dev must be positive")
}
return 0.5 * (1 + math.Erf((x-mean)/(stdDev*math.Sqrt2))), nil
}
// BinomialPMF returns P(X = k) for X ~ Binomial(n, p).
func BinomialPMF(k, n int, p float64) (float64, error) {
if n < 0 || k < 0 || k > n {
return 0, fmt.Errorf("binomial_pmf: require 0 <= k <= n, got k=%d n=%d", k, n)
}
if p < 0 || p > 1 {
return 0, fmt.Errorf("binomial_pmf: p must be in [0, 1], got %g", p)
}
return binomialCoeff(n, k) * math.Pow(p, float64(k)) * math.Pow(1-p, float64(n-k)), nil
}
// binomialCoeff computes C(n, k) via the multiplicative formula,
// which stays in float64 range far longer than computing n!/(k!(n-k)!)
// from raw factorials would.
func binomialCoeff(n, k int) float64 {
if k < 0 || k > n {
return 0
}
if k > n-k {
k = n - k
}
result := 1.0
for i := 0; i < k; i++ {
result *= float64(n-i) / float64(i+1)
}
return result
}

557
pkg/officekit/docx.go Normal file
View File

@@ -0,0 +1,557 @@
package officekit
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"regexp"
"strconv"
"strings"
)
// ParaStyle is the small, Claude-Code-skill-inspired vocabulary of
// paragraph styles the model is offered — deliberately not "any OOXML
// style name", so the model can never need to type XML to format
// something.
type ParaStyle string
const (
StyleNormal ParaStyle = "normal"
StyleHeading1 ParaStyle = "heading1"
StyleHeading2 ParaStyle = "heading2"
StyleHeading3 ParaStyle = "heading3"
StyleBullet ParaStyle = "bullet"
)
// Paragraph is one structured paragraph of a Word document, as the
// model supplies it via the create_document/edit_document tool
// arguments — plain fields, never markup.
type Paragraph struct {
Text string
Style ParaStyle
Bold bool
Italic bool
Underline bool
}
const docxDocumentPart = "word/document.xml"
// --- word/document.xml, built with encoding/xml so every run of text
// is correctly escaped no matter what characters it contains (a stray
// "<" or "&" in a paragraph's text becomes "&lt;"/"&amp;" the same way
// Go's XML encoder would escape it anywhere else — it can never end up
// as literal, parseable-looking markup). ---
// wDocument's XMLName tag uses Go's "namespace-URI local-name" form
// (space-separated, not a colon prefix — see the package doc comment
// in zipkit.go's header and this file's note above BuildDocx for why
// colon-prefixed tags like "w:document" don't round-trip). Marshal
// emits this as the default namespace (xmlns="...") on the root
// element; every child struct below uses a plain, unprefixed local
// name with no namespace component, which both (a) is valid XML —
// unprefixed descendants simply inherit the nearest enclosing default
// namespace — and (b) unmarshals back correctly, because an
// unnamespaced field tag matches an element by local name only,
// regardless of which namespace it actually resolved to.
type wDocument struct {
XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main document"`
Body wBody `xml:"body"`
}
type wBody struct {
Paragraphs []wParagraph `xml:"p"`
SectPr wSectPr `xml:"sectPr"`
}
type wSectPr struct {
PgSz wPgSz `xml:"pgSz"`
}
type wPgSz struct {
W int `xml:"w,attr"`
H int `xml:"h,attr"`
}
type wParagraph struct {
PPr *wPPr `xml:"pPr,omitempty"`
Run wRun `xml:"r"`
}
type wPPr struct {
NumPr *wNumPr `xml:"numPr,omitempty"`
}
type wNumPr struct {
Ilvl wVal `xml:"ilvl"`
NumId wVal `xml:"numId"`
}
type wVal struct {
Val int `xml:"val,attr"`
}
type wRun struct {
RPr *wRPr `xml:"rPr,omitempty"`
Text wText `xml:"t"`
}
// wRPr's field order follows OOXML's CT_RPr schema sequence (b, i,
// sz, u, ...) — Go's xml.Marshal emits struct fields in declaration
// order, and while real-world readers (Word included) tend to be
// lenient about run-property order, writing it correctly costs
// nothing and keeps generated files schema-valid, not just
// Word-openable.
type wRPr struct {
Bold *struct{} `xml:"b,omitempty"`
Italic *struct{} `xml:"i,omitempty"`
Sz *wSz `xml:"sz,omitempty"`
Underline *wU `xml:"u,omitempty"`
}
type wSz struct {
Val int `xml:"val,attr"`
}
type wU struct {
Val string `xml:"val,attr"`
}
type wText struct {
Space string `xml:"xml:space,attr"`
Value string `xml:",chardata"`
}
// wParagraphFragment is the same content as wParagraph, but with its
// own explicit WordprocessingML namespace declaration on XMLName.
// It exists only for AppendDocxParagraph, which marshals ONE paragraph
// standalone and splices its bytes directly into an existing
// document.xml rather than re-marshaling the whole document (see that
// function's doc comment for why). A spliced fragment has no parent
// element to inherit a namespace from if the target document uses
// Word's own "w:"-prefixed convention instead of this package's
// default-namespace one — so this type redeclares the namespace
// locally on the fragment's own root element. XML lets any element do
// that regardless of its surrounding document's prefix convention, and
// every OOXML-consuming application (Word included) resolves element
// identity by namespace URI + local name, not by literal prefix text,
// so `<p xmlns="...wordprocessingml.../main">` and `<w:p>` are the same
// element as far as Word is concerned. wParagraph itself deliberately
// keeps no such tag, so BuildDocx's normal output — where every
// paragraph already inherits the root's default namespace — doesn't
// pay for a redundant xmlns on every single paragraph.
type wParagraphFragment struct {
XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main p"`
PPr *wPPr `xml:"pPr,omitempty"`
Run wRun `xml:"r"`
}
func newParagraphFragment(p Paragraph) wParagraphFragment {
wp := toWParagraph(p)
return wParagraphFragment{PPr: wp.PPr, Run: wp.Run}
}
// runProps maps the model's small formatting vocabulary (bold, italic,
// underline, plus the size/boldness implied by a heading style) to a
// run's rPr, or nil if the run needs no rPr at all (the common case:
// a plain, unstyled normal/bullet paragraph).
func runProps(p Paragraph) *wRPr {
bold, italic, underline := p.Bold, p.Italic, p.Underline
var sz *wSz
switch p.Style {
case StyleHeading1:
bold = true
sz = &wSz{Val: 32} // 16pt
case StyleHeading2:
bold = true
sz = &wSz{Val: 28} // 14pt
case StyleHeading3:
bold = true
sz = &wSz{Val: 24} // 12pt
}
if !bold && !italic && !underline && sz == nil {
return nil
}
rpr := &wRPr{Sz: sz}
if bold {
rpr.Bold = &struct{}{}
}
if italic {
rpr.Italic = &struct{}{}
}
if underline {
rpr.Underline = &wU{Val: "single"}
}
return rpr
}
func toWParagraph(p Paragraph) wParagraph {
wp := wParagraph{
Run: wRun{
RPr: runProps(p),
Text: wText{Space: "preserve", Value: p.Text},
},
}
if p.Style == StyleBullet {
wp.PPr = &wPPr{NumPr: &wNumPr{Ilvl: wVal{Val: 0}, NumId: wVal{Val: 1}}}
}
return wp
}
// BuildDocx renders paragraphs into a complete, valid .docx file.
func BuildDocx(paragraphs []Paragraph) ([]byte, error) {
body := wBody{SectPr: wSectPr{PgSz: wPgSz{W: 12240, H: 15840}}}
for _, p := range paragraphs {
body.Paragraphs = append(body.Paragraphs, toWParagraph(p))
}
doc := wDocument{Body: body}
docXML, err := xml.Marshal(doc)
if err != nil {
return nil, fmt.Errorf("officekit: marshal document.xml: %w", err)
}
// A minimal numbering part is included (word/numbering.xml) only so
// StyleBullet's w:numId="1" resolves to something rather than being
// a dangling reference; every bullet paragraph shares the same
// single bullet-list definition.
parts := []zipPart{
{Name: "[Content_Types].xml", Body: docxContentTypes},
{Name: "_rels/.rels", Body: docxRootRels},
{Name: "word/_rels/document.xml.rels", Body: docxDocumentRels},
{Name: docxDocumentPart, Body: xml.Header + string(docXML)},
{Name: "word/numbering.xml", Body: docxNumberingXML},
{Name: "docProps/core.xml", Body: officeCoreProps},
{Name: "docProps/app.xml", Body: docxAppProps},
}
return buildZip(parts)
}
// ReadDocxText extracts the plain text of a .docx file, one line per
// paragraph (bullets prefixed with "- "), for the read_document tool.
// This is what the model actually sees — never the underlying XML.
//
// This walks document.xml's raw token stream rather than unmarshaling
// into a struct that only models direct <w:body> children: a real
// document's paragraphs can be nested inside a table cell
// (<w:tbl>/<w:tr>/<w:tc>/<w:p>), and a struct-based Unmarshal into
// wBody.Paragraphs would silently skip every one of those, making a
// user-uploaded document with tables look emptier than it is. The
// token walk picks up every <w:p> wherever it occurs, in document
// order; it still doesn't reconstruct a table's row/column grid (see
// the docx skill's documented limitation) — each cell's paragraphs
// simply appear as their own lines.
func ReadDocxText(data []byte) (string, error) {
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
lines, err := walkParagraphText(raw)
if err != nil {
return "", fmt.Errorf("officekit: parse document.xml: %w", err)
}
return strings.Join(lines, "\n"), nil
}
// walkParagraphText returns one text line per <w:p> paragraph found
// anywhere in document.xml, in document order: the concatenation of
// every <w:t> run's text within it, with <w:tab/> rendered as a
// literal tab, prefixed "- " when the paragraph carries a <w:numPr>
// (list) property. Matches by local element name only (see the
// package-level namespace note on wDocument), so it works the same
// whether the source uses Word's "w:" prefix or this package's own
// default-namespace convention.
func walkParagraphText(raw []byte) ([]string, error) {
dec := xml.NewDecoder(bytes.NewReader(raw))
var (
lines []string
cur strings.Builder
inPara bool
inText bool
isBullet bool
)
flushPara := func() {
text := cur.String()
if isBullet {
text = "- " + text
}
lines = append(lines, text)
cur.Reset()
inPara, isBullet = false, false
}
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
switch t := tok.(type) {
case xml.StartElement:
switch t.Name.Local {
case "p":
inPara = true
case "t":
inText = true
case "tab":
if inPara {
cur.WriteByte('\t')
}
case "numPr":
if inPara {
isBullet = true
}
}
case xml.EndElement:
switch t.Name.Local {
case "p":
if inPara {
flushPara()
}
case "t":
inText = false
}
case xml.CharData:
if inPara && inText {
cur.Write(t)
}
}
}
return lines, nil
}
// AppendDocxParagraph adds one paragraph as the new last content
// element of the document body and returns the new file bytes.
//
// This splices the new paragraph's marshaled XML directly into
// document.xml's existing raw bytes, immediately before the body's
// trailing <w:sectPr> (OOXML requires section properties to be the
// body's last child) — or immediately before </w:body> if there's no
// sectPr at all — instead of the unmarshal-modify-marshal round trip
// this used to share with ReplaceDocxText. That round trip only ever
// modeled direct <w:body> children (paragraphs and the trailing
// sectPr), so re-marshaling it back would silently drop everything
// else a real document has: tables, images, headers, multiple runs
// per paragraph, tracked changes. Splicing means every byte of the
// original document this package doesn't model survives untouched.
func AppendDocxParagraph(data []byte, p Paragraph) ([]byte, error) {
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
paraXML, err := xml.Marshal(newParagraphFragment(p))
if err != nil {
return nil, fmt.Errorf("officekit: marshal paragraph: %w", err)
}
at := bodyInsertionPoint(raw)
if at < 0 {
return nil, fmt.Errorf("officekit: %s has no <w:body> to append to (not a valid .docx)", docxDocumentPart)
}
newDoc := make([]byte, 0, len(raw)+len(paraXML))
newDoc = append(newDoc, raw[:at]...)
newDoc = append(newDoc, paraXML...)
newDoc = append(newDoc, raw[at:]...)
return replaceZipPart(data, docxDocumentPart, newDoc)
}
// bodyInsertionPoint returns the byte offset in document.xml where new
// body content should be spliced in: right before the body's trailing
// <w:sectPr> if present, else right before </w:body>. Returns -1 if
// neither tag is found at all (not a recognizable Word document body).
func bodyInsertionPoint(doc []byte) int {
for _, tag := range []string{"<w:sectPr", "<sectPr"} {
if i := lastTagIndex(doc, tag); i >= 0 {
return i
}
}
for _, tag := range []string{"</w:body>", "</body>"} {
if i := bytes.Index(doc, []byte(tag)); i >= 0 {
return i
}
}
return -1
}
// lastTagIndex finds the last byte offset of an opening tag whose name
// is exactly tag (e.g. "<w:sectPr"), rejecting a longer tag name that
// merely starts with the same bytes — such as the tracked-changes
// element "<w:sectPrChange", which would otherwise look like a match.
func lastTagIndex(doc []byte, tag string) int {
needle := []byte(tag)
end := len(doc)
for {
i := bytes.LastIndex(doc[:end], needle)
if i < 0 {
return -1
}
after := i + len(needle)
if after >= len(doc) {
return i
}
switch doc[after] {
case ' ', '\t', '\n', '\r', '>', '/':
return i
}
end = i // false positive (e.g. sectPrChange); keep searching earlier
}
}
// wTextElementRe matches one WordprocessingML text run's element —
// <w:t>...</w:t>, the unprefixed <t>...</t> this package's own output
// uses, or any other single-letter-prefixed variant — capturing the
// opening tag, the raw (still XML-escaped) inner text, and the closing
// tag separately, so ReplaceDocxText only ever rewrites the inner text
// and leaves the tag itself, and everything outside it, byte-for-byte
// untouched. The boundary check after the local name (next character
// must be whitespace, "/", or ">") excludes longer names that merely
// start with "t", such as "tab", "tbl", or "tc".
var wTextElementRe = regexp.MustCompile(`(?s)(<(?:[A-Za-z][\w.-]*:)?t(?:\s[^>]*)?>)(.*?)(</(?:[A-Za-z][\w.-]*:)?t>)`)
// ReplaceDocxText replaces every exact, literal occurrence of find
// with replace inside the document's text runs — including runs
// nested inside a table cell, which the old paragraph-struct-based
// approach never saw at all — and returns the new file bytes plus how
// many text runs were changed. It rewrites only the matched <w:t>
// elements' inner text in document.xml's raw bytes; every other part
// of the document (formatting, tables, images, headers) passes through
// completely untouched.
//
// Because Word often splits a single sentence across more than one run
// (spell-check and revision tracking both do this), a phrase that
// spans a run boundary may not be found even though it reads as one
// phrase in Word — read_document first, and prefer a shorter, more
// literal match string when a longer one doesn't seem to apply.
func ReplaceDocxText(data []byte, find, replace string) ([]byte, int, error) {
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return nil, 0, err
}
if !ok {
return nil, 0, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
changed := 0
newDoc := wTextElementRe.ReplaceAllFunc(raw, func(match []byte) []byte {
groups := wTextElementRe.FindSubmatch(match)
open, inner, closeTag := groups[1], groups[2], groups[3]
decoded := xmlUnescapeText(inner)
if !strings.Contains(decoded, find) {
return match
}
changed++
replaced := strings.ReplaceAll(decoded, find, replace)
out := make([]byte, 0, len(open)+len(replaced)+len(closeTag))
out = append(out, open...)
out = append(out, []byte(xmlEscapeText(replaced))...)
out = append(out, closeTag...)
return out
})
if changed == 0 {
return nil, 0, fmt.Errorf("officekit: %q not found in any text run", find)
}
out, err := replaceZipPart(data, docxDocumentPart, newDoc)
return out, changed, err
}
// xmlUnescapeText reverses XML's five predefined entities plus numeric
// character references (&#NNN; / &#xHH;) — the inverse of
// xmlEscapeText (pptx.go) — so ReplaceDocxText can match and replace
// against the plain text the model actually passed, not its
// XML-escaped form. An unknown or malformed entity is left verbatim
// rather than erroring: this only ever feeds a literal-substring
// comparison, so there's nothing to fail loudly about.
func xmlUnescapeText(b []byte) string {
s := string(b)
if !strings.ContainsRune(s, '&') {
return s
}
var out strings.Builder
for {
i := strings.IndexByte(s, '&')
if i < 0 {
out.WriteString(s)
break
}
out.WriteString(s[:i])
rest := s[i+1:]
semi := strings.IndexByte(rest, ';')
if semi < 0 {
out.WriteByte('&')
s = rest
continue
}
entity, tail := rest[:semi], rest[semi+1:]
switch {
case entity == "amp":
out.WriteByte('&')
case entity == "lt":
out.WriteByte('<')
case entity == "gt":
out.WriteByte('>')
case entity == "apos":
out.WriteByte('\'')
case entity == "quot":
out.WriteByte('"')
case strings.HasPrefix(entity, "#x"), strings.HasPrefix(entity, "#X"):
if n, err := strconv.ParseInt(entity[2:], 16, 32); err == nil {
out.WriteRune(rune(n))
} else {
out.WriteByte('&')
out.WriteString(entity)
out.WriteByte(';')
}
case strings.HasPrefix(entity, "#"):
if n, err := strconv.ParseInt(entity[1:], 10, 32); err == nil {
out.WriteRune(rune(n))
} else {
out.WriteByte('&')
out.WriteString(entity)
out.WriteByte(';')
}
default:
out.WriteByte('&')
out.WriteString(entity)
out.WriteByte(';')
}
s = tail
}
return out.String()
}
const docxContentTypes = xml.Header + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
</Types>`
const docxRootRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>`
const docxDocumentRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>
</Relationships>`
const docxNumberingXML = xml.Header + `<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:abstractNum w:abstractNumId="0">
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val=""/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl>
</w:abstractNum>
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
</w:numbering>`
const docxAppProps = xml.Header + `<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>enkly</Application></Properties>`
// officeCoreProps is shared verbatim by docx/xlsx/pptx — the schema is
// package-level (docProps/core.xml), not format-specific.
const officeCoreProps = xml.Header + `<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:creator>enkly agent</dc:creator></cp:coreProperties>`

128
pkg/officekit/markdown.go Normal file
View File

@@ -0,0 +1,128 @@
// markdown.go adds Markdown → the same structured []Paragraph
// vocabulary create_document and BuildDocx already use, so an
// existing Markdown document (a README, a chat reply, something an
// upload already contains) can become a real .docx without the model
// ever hand-translating headings and bullets into paragraph arguments
// itself. It reuses the gomarkdown parser already vendored for
// chat-message rendering (pkg/renderkit) rather than hand-rolling a
// second Markdown parser — one less thing in this codebase that can
// disagree about what counts as a heading or a list item.
//
// Scope: block-level structure only — headings, paragraphs, and
// bullet/numbered lists (numbered lists render as bullets, since
// Paragraph has no numbered-list style of its own yet). Fenced or
// indented code blocks come through as plain paragraphs, since
// Paragraph has no monospace style either. Inline formatting
// (bold/italic spans within a paragraph, links, inline code) is
// flattened to plain text — Paragraph only supports whole-paragraph
// bold/italic/underline (see docx.go), the same limit
// create_document's tool description already states. Tables, raw HTML
// blocks, footnotes, and anything else without a Paragraph-vocabulary
// equivalent are silently dropped rather than guessed at.
package officekit
import (
"strings"
"github.com/gomarkdown/markdown/ast"
mdparser "github.com/gomarkdown/markdown/parser"
)
// MarkdownToParagraphs parses a Markdown document into the paragraph
// vocabulary BuildDocx/AppendDocxParagraph already understand, so a
// caller can go straight from Markdown source to a .docx via
// BuildDocx(MarkdownToParagraphs(src)).
func MarkdownToParagraphs(src string) []Paragraph {
p := mdparser.NewWithExtensions(mdparser.CommonExtensions)
doc := p.Parse([]byte(src))
var out []Paragraph
for _, block := range childNodes(doc) {
out = append(out, blockToParagraphs(block)...)
}
return out
}
func blockToParagraphs(n ast.Node) []Paragraph {
switch v := n.(type) {
case *ast.Heading:
text := leafText(v)
if text == "" {
return nil
}
style := StyleHeading3
switch {
case v.Level <= 1:
style = StyleHeading1
case v.Level == 2:
style = StyleHeading2
}
return []Paragraph{{Text: text, Style: style}}
case *ast.Paragraph:
text := leafText(v)
if text == "" {
return nil
}
return []Paragraph{{Text: text, Style: StyleNormal}}
case *ast.List:
var out []Paragraph
for _, item := range childNodes(v) {
text := leafText(item)
if text == "" {
continue
}
out = append(out, Paragraph{Text: text, Style: StyleBullet})
}
return out
case *ast.CodeBlock:
text := strings.TrimRight(string(v.Literal), "\n")
if text == "" {
return nil
}
return []Paragraph{{Text: text, Style: StyleNormal}}
case *ast.BlockQuote:
var out []Paragraph
for _, c := range childNodes(v) {
out = append(out, blockToParagraphs(c)...)
}
return out
default:
// HorizontalRule, Table, HTMLBlock, and anything else this
// package has no Paragraph-vocabulary equivalent for.
return nil
}
}
// childNodes returns n's direct block-level children via gomarkdown's
// Container/Leaf node split: a Leaf node (Text, Code, ...) has none.
func childNodes(n ast.Node) []ast.Node {
if c := n.AsContainer(); c != nil {
return c.Children
}
return nil
}
// leafText concatenates every Text/Code leaf found under n, depth
// first — the inline-formatting-flattening step described in this
// file's package doc.
func leafText(n ast.Node) string {
var sb strings.Builder
collectText(n, &sb)
return strings.TrimSpace(sb.String())
}
func collectText(n ast.Node, sb *strings.Builder) {
switch v := n.(type) {
case *ast.Text:
sb.Write(v.Literal)
return
case *ast.Code:
sb.Write(v.Literal)
return
case *ast.Hardbreak:
sb.WriteByte(' ')
return
}
for _, c := range childNodes(n) {
collectText(c, sb)
}
}

View File

@@ -0,0 +1,74 @@
package officekit
import "testing"
func TestMarkdownToParagraphsStructure(t *testing.T) {
src := "# Title\n\nAn intro paragraph.\n\n## Section\n\n- First item\n- Second item\n"
paras := MarkdownToParagraphs(src)
want := []Paragraph{
{Text: "Title", Style: StyleHeading1},
{Text: "An intro paragraph.", Style: StyleNormal},
{Text: "Section", Style: StyleHeading2},
{Text: "First item", Style: StyleBullet},
{Text: "Second item", Style: StyleBullet},
}
if len(paras) != len(want) {
t.Fatalf("got %d paragraphs, want %d: %+v", len(paras), len(want), paras)
}
for i := range want {
if paras[i].Text != want[i].Text || paras[i].Style != want[i].Style {
t.Errorf("paragraph %d: got %+v, want %+v", i, paras[i], want[i])
}
}
}
func TestMarkdownToParagraphsFlattensInlineFormatting(t *testing.T) {
paras := MarkdownToParagraphs("This has **bold** and *italic* and `code` text.")
if len(paras) != 1 {
t.Fatalf("got %d paragraphs, want 1: %+v", len(paras), paras)
}
want := "This has bold and italic and code text."
if paras[0].Text != want {
t.Errorf("got %q, want %q", paras[0].Text, want)
}
}
func TestMarkdownToParagraphsFeedsBuildDocx(t *testing.T) {
paras := MarkdownToParagraphs("# Report\n\nBody text here.\n")
data, err := BuildDocx(paras)
if err != nil {
t.Fatalf("BuildDocx(MarkdownToParagraphs(...)): %v", err)
}
text, err := ReadDocxText(data)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
for _, want := range []string{"Report", "Body text here."} {
if !containsLine(text, want) {
t.Errorf("expected read-back text to contain line %q, got:\n%s", want, text)
}
}
}
func containsLine(text, line string) bool {
for _, l := range splitLines(text) {
if l == line {
return true
}
}
return false
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
lines = append(lines, s[start:])
return lines
}

View File

@@ -0,0 +1,274 @@
package officekit
import (
"encoding/xml"
"fmt"
"strings"
"testing"
)
// minimalWordDoc builds a tiny, realistic .docx — using Word's own
// "w:"-prefixed convention, not this package's unprefixed
// default-namespace one — containing one body paragraph and one table
// with a single cell's worth of text. This exercises the append/replace
// operations against content this package doesn't itself model (tables)
// without needing a real binary fixture file on disk.
func minimalWordDoc(t *testing.T) []byte {
t.Helper()
documentXML := xml.Header + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
`<w:body>` +
`<w:p><w:r><w:t>Intro paragraph.</w:t></w:r></w:p>` +
`<w:tbl><w:tr><w:tc><w:p><w:r><w:t>cell one text</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
`<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>` +
`</w:body></w:document>`
data, err := buildZip([]zipPart{
{Name: "[Content_Types].xml", Body: docxContentTypes},
{Name: "_rels/.rels", Body: docxRootRels},
{Name: docxDocumentPart, Body: documentXML},
})
if err != nil {
t.Fatalf("buildZip: %v", err)
}
return data
}
func TestBuildDocxReadRoundTrip(t *testing.T) {
data, err := BuildDocx([]Paragraph{
{Text: "Title", Style: StyleHeading1},
{Text: "First item", Style: StyleBullet},
{Text: "Second item", Style: StyleBullet},
{Text: "Styled body text", Bold: true, Italic: true, Underline: true},
})
if err != nil {
t.Fatalf("BuildDocx: %v", err)
}
text, err := ReadDocxText(data)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
want := []string{"Title", "- First item", "- Second item", "Styled body text"}
got := strings.Split(text, "\n")
if len(got) != len(want) {
t.Fatalf("expected %d lines, got %d: %q", len(want), len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("line %d: got %q, want %q", i, got[i], want[i])
}
}
}
func TestDocxRunFormattingXML(t *testing.T) {
data, err := BuildDocx([]Paragraph{{Text: "x", Bold: true, Italic: true, Underline: true}})
if err != nil {
t.Fatalf("BuildDocx: %v", err)
}
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil || !ok {
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
}
body := string(raw)
for _, want := range []string{"<b></b>", "<i></i>", `<u val="single">`} {
if !strings.Contains(body, want) {
t.Errorf("expected document.xml to contain %s, got:\n%s", want, body)
}
}
}
func TestAppendDocxParagraphPreservesTable(t *testing.T) {
data := minimalWordDoc(t)
out, err := AppendDocxParagraph(data, Paragraph{Text: "Appended line."})
if err != nil {
t.Fatalf("AppendDocxParagraph: %v", err)
}
text, err := ReadDocxText(out)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
for _, want := range []string{"Intro paragraph.", "cell one text", "Appended line."} {
if !strings.Contains(text, want) {
t.Errorf("expected read-back text to contain %q, got:\n%s", want, text)
}
}
// The new paragraph must land before the trailing sectPr, not after
// it — content after a body's final sectPr is invalid OOXML.
raw, ok, err := readZipPart(out, docxDocumentPart)
if err != nil || !ok {
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
}
body := string(raw)
idxAppended := strings.Index(body, "Appended line.")
idxSectPr := strings.Index(body, "<w:sectPr")
if idxAppended < 0 || idxSectPr < 0 || idxAppended > idxSectPr {
t.Errorf("expected appended paragraph before <w:sectPr>, got appended@%d sectPr@%d:\n%s", idxAppended, idxSectPr, body)
}
}
func TestReplaceDocxTextInTable(t *testing.T) {
data := minimalWordDoc(t)
out, n, err := ReplaceDocxText(data, "cell one text", "updated cell text")
if err != nil {
t.Fatalf("ReplaceDocxText: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 text run changed, got %d", n)
}
text, err := ReadDocxText(out)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
if strings.Contains(text, "cell one text") {
t.Errorf("old text still present after replace: %s", text)
}
if !strings.Contains(text, "updated cell text") {
t.Errorf("expected replaced text, got: %s", text)
}
if !strings.Contains(text, "Intro paragraph.") {
t.Errorf("unrelated paragraph lost: %s", text)
}
}
func TestReplaceDocxTextNotFound(t *testing.T) {
data := minimalWordDoc(t)
if _, _, err := ReplaceDocxText(data, "nope, not in this document", "x"); err == nil {
t.Fatal("expected an error for text that isn't present")
}
}
func TestSetXlsxCellsFormulaAndOrdering(t *testing.T) {
data, err := BuildXlsx([]Sheet{{Name: "Sheet1", Rows: [][]string{{"Label", "Value"}}}})
if err != nil {
t.Fatalf("BuildXlsx: %v", err)
}
// Written deliberately out of row order, including a formula, to
// exercise the sort-before-marshal fix.
out, err := SetXlsxCells(data, "", map[string]string{
"A5": "fifth",
"A2": "second",
"B2": "=A2",
"A1": "first",
})
if err != nil {
t.Fatalf("SetXlsxCells: %v", err)
}
rows, _, err := ReadXlsxCells(out, "")
if err != nil {
t.Fatalf("ReadXlsxCells: %v", err)
}
// Three sparse rows exist: 1 (from BuildXlsx + overwritten A1), 2, 5.
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d: %v", len(rows), rows)
}
if rows[0][0] != "first" {
t.Errorf("row 1 col A: got %q, want %q", rows[0][0], "first")
}
if rows[1][0] != "second" {
t.Errorf("row 2 col A: got %q, want %q", rows[1][0], "second")
}
if len(rows[1]) < 2 || rows[1][1] != "=A2" {
t.Errorf("row 2 col B: got %v, want formula \"=A2\"", rows[1])
}
if rows[2][0] != "fifth" {
t.Errorf("row 5 col A: got %q, want %q", rows[2][0], "fifth")
}
// A real reader (Excel included) cares about the underlying
// element order, not just what this package's own reader derives —
// confirm the raw XML itself is in ascending row/column order.
raw, ok, err := readZipPart(out, xlsxSheetPart(1))
if err != nil || !ok {
t.Fatalf("readZipPart sheet1: ok=%v err=%v", ok, err)
}
body := string(raw)
idxA1 := strings.Index(body, `r="A1"`)
idxA2 := strings.Index(body, `r="A2"`)
idxB2 := strings.Index(body, `r="B2"`)
idxA5 := strings.Index(body, `r="A5"`)
if idxA1 < 0 || idxA2 < 0 || idxB2 < 0 || idxA5 < 0 {
t.Fatalf("expected every written cell ref present in raw XML:\n%s", body)
}
if !(idxA1 < idxA2 && idxA2 < idxB2 && idxB2 < idxA5) {
t.Errorf("expected ascending order A1 < A2 < B2 < A5 in raw XML, got offsets %d,%d,%d,%d:\n%s",
idxA1, idxA2, idxB2, idxA5, body)
}
}
// TestSetXlsxCellsManyNewRowsNoDataLoss is a regression test for a bug
// where SetXlsxCells indexed newly-created rows by a *pointer* taken
// at append time. Go's append reallocates a slice's backing array
// once its capacity is exceeded, which silently strands any
// previously-taken pointer into the old array — a write applied
// through it returns no error but never reaches the file. That bug
// only showed up when (a) more than one cell landed in the same
// brand-new row and (b) a slice reallocation happened to fall between
// those two writes, so it passed or failed depending on Go's
// (deliberately randomized) map iteration order. This test forces
// many reallocations (far more new rows than any small initial slice
// capacity) and writes two cells into every one of them, so a
// reintroduction of the pointer bug fails deterministically rather
// than intermittently.
func TestSetXlsxCellsManyNewRowsNoDataLoss(t *testing.T) {
data, err := BuildXlsx([]Sheet{{Name: "Sheet1", Rows: [][]string{{"seed"}}}})
if err != nil {
t.Fatalf("BuildXlsx: %v", err)
}
const numRows = 200
writes := make(map[string]string, numRows*2)
for r := 2; r <= numRows+1; r++ {
writes[fmt.Sprintf("A%d", r)] = fmt.Sprintf("label-%d", r)
writes[fmt.Sprintf("B%d", r)] = fmt.Sprintf("=A%d", r)
}
out, err := SetXlsxCells(data, "", writes)
if err != nil {
t.Fatalf("SetXlsxCells: %v", err)
}
rows, _, err := ReadXlsxCells(out, "")
if err != nil {
t.Fatalf("ReadXlsxCells: %v", err)
}
if len(rows) != numRows+1 { // +1 for the seeded row 1
t.Fatalf("expected %d rows, got %d", numRows+1, len(rows))
}
for r := 2; r <= numRows+1; r++ {
row := rows[r-1]
if len(row) != 2 {
t.Fatalf("row %d: expected 2 cells, got %d: %v", r, len(row), row)
}
wantA := fmt.Sprintf("label-%d", r)
wantB := fmt.Sprintf("=A%d", r)
if row[0] != wantA {
t.Errorf("row %d col A: got %q, want %q", r, row[0], wantA)
}
if row[1] != wantB {
t.Errorf("row %d col B: got %q, want %q", r, row[1], wantB)
}
}
}
func TestBuildPptxReadRoundTrip(t *testing.T) {
data, err := BuildPptx([]Slide{
{Title: "Welcome"},
{Title: "Agenda", Bullets: []string{"Point one", "Point two & three"}},
})
if err != nil {
t.Fatalf("BuildPptx: %v", err)
}
slides, err := ReadPptxSlides(data)
if err != nil {
t.Fatalf("ReadPptxSlides: %v", err)
}
if len(slides) != 2 {
t.Fatalf("expected 2 slides, got %d", len(slides))
}
if slides[0].Title != "Welcome" {
t.Errorf("slide 1 title: got %q", slides[0].Title)
}
if slides[1].Title != "Agenda" || len(slides[1].Bullets) != 2 || slides[1].Bullets[1] != "Point two & three" {
t.Errorf("slide 2: got %+v", slides[1])
}
}

206
pkg/officekit/pdf.go Normal file
View File

@@ -0,0 +1,206 @@
// pdf.go adds best-effort PDF text extraction: read_pdf's only job is
// "give me back the words", not full-fidelity layout, so this
// deliberately stays a few hundred lines of pure stdlib (no PDF
// library, no cgo, nothing to go get in an offline deployment) rather
// than a complete PDF renderer. It walks the file's raw bytes for
// `stream ... endstream` blocks, inflates the ones declared
// FlateDecode (the overwhelming majority of PDFs produced by real
// tools — Word, LibreOffice, browsers, Pandoc, Pages — all compress
// their content streams this way), and scans the decompressed
// PostScript-like content for the handful of text-showing operators
// (Tj, TJ, ', ") that actually place characters on the page.
// Everything else in the file — fonts, images, vector graphics,
// annotations, page geometry — is ignored.
//
// What this can't do, and won't pretend to: scanned/image-only PDFs
// (no embedded text at all — that needs OCR, out of scope here),
// PDFs whose content streams use a filter other than FlateDecode
// (rare in practice), and any PDF that encodes its text with a custom
// font encoding and no usable mapping back to plain characters — those
// come out as empty or as mangled text, and ReadPdfText says so rather
// than silently returning garbage.
package officekit
import (
"bytes"
"compress/zlib"
"fmt"
"io"
"regexp"
"strconv"
"strings"
)
// pdfStreamRe finds every `stream\r?\n ... \r?\nendstream` block in
// the file, capturing the body separately from the "stream" keyword
// that precedes it — ReadPdfText looks backward from the keyword for
// the object dictionary's /Filter entry rather than actually parsing
// PDF's object/dictionary syntax.
var pdfStreamRe = regexp.MustCompile(`(?s)stream\r?\n(.*?)\r?\nendstream`)
// pdfDictLookback is how far back from a `stream` keyword ReadPdfText
// looks for that stream object's dictionary (and in particular its
// /Filter entry) — generous enough for any realistic content-stream
// dictionary (/Length, /Filter, /Length1, a handful of other keys)
// without risking bleeding into the previous object.
const pdfDictLookback = 600
// ReadPdfText extracts a PDF's plain text content, best-effort. See
// the package doc above for exactly what it does and doesn't handle.
// Returns an error only when the input isn't recognizable as a PDF,
// or no extractable text was found anywhere in it — a PDF that fails
// to extract from some streams but not others still returns whatever
// text it did recover from the rest.
func ReadPdfText(data []byte) (string, error) {
head := data
if len(head) > 1024 {
head = head[:1024]
}
if !bytes.Contains(head, []byte("%PDF-")) {
return "", fmt.Errorf("officekit: not a PDF file (missing %%PDF- header)")
}
var out strings.Builder
for _, loc := range pdfStreamRe.FindAllSubmatchIndex(data, -1) {
streamStart, bodyStart, bodyEnd := loc[0], loc[2], loc[3]
dictStart := streamStart - pdfDictLookback
if dictStart < 0 {
dictStart = 0
}
dict := data[dictStart:streamStart]
if !bytes.Contains(dict, []byte("FlateDecode")) {
continue // not a stream this package knows how to decompress
}
inflated, err := inflateZlib(data[bodyStart:bodyEnd])
if err != nil {
continue // FlateDecode-tagged but not plain DEFLATE (e.g. an embedded font program); skip it
}
if text := extractShownText(inflated); text != "" {
if out.Len() > 0 {
out.WriteString("\n\n")
}
out.WriteString(text)
}
}
result := strings.TrimSpace(out.String())
if result == "" {
return "", fmt.Errorf("officekit: no extractable text found (the PDF may be scanned/image-only, or use an encoding this reader doesn't support)")
}
return result, nil
}
func inflateZlib(b []byte) ([]byte, error) {
r, err := zlib.NewReader(bytes.NewReader(b))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// pdfTextOpRe matches one text-showing operator call together with
// its operand(s): one or more PDF "literal strings" — parenthesized,
// with their own backslash-escape syntax decoded by
// pdfUnescapeLiteral — followed by Tj, TJ, ' or ". A TJ call's operand
// is actually an array, e.g. `[(Hel)-20(lo)] TJ`, which is why this
// matches "one or more" consecutive literals rather than exactly one.
var pdfTextOpRe = regexp.MustCompile(`(?s)((?:\((?:\\.|[^()\\])*\)[^()]*?)+)(Tj|TJ|'|")`)
// pdfLiteralRe pulls each individual (...) literal string out of one
// operator call's operand span.
var pdfLiteralRe = regexp.MustCompile(`(?s)\((?:\\.|[^()\\])*\)`)
// pdfNewlineOpRe matches the content-stream operators that move the
// text cursor to a new line (Td, TD, T*) — used only as a heuristic
// for where extractShownText inserts a newline into the recovered
// text, since the stream itself carries no notion of "paragraph" or
// "line" beyond cursor movement.
var pdfNewlineOpRe = regexp.MustCompile(`\bT[dD*]\b`)
// extractShownText walks one decompressed content stream in document
// order and concatenates the string(s) passed to each text-showing
// operator call, inserting a newline wherever a line-positioning
// operator (Td/TD/T*) appeared since the previous call and a plain
// space otherwise — an approximation of the stream's original line
// breaks, not a layout engine. A single TJ call's several literals
// (PDF splits one word across more than one literal to carry kerning
// adjustments, e.g. `[(Hel)-20(lo)] TJ`) are joined with nothing
// between them, since the gap is a spacing nudge, not a real word
// break — any actual space in the shown text already appears as a
// literal " " character the way it does in a Tj call.
func extractShownText(stream []byte) string {
var sb strings.Builder
pos := 0
pendingNewline := false
for pos < len(stream) {
loc := pdfTextOpRe.FindSubmatchIndex(stream[pos:])
if loc == nil {
break
}
matchStart, matchEnd := pos+loc[0], pos+loc[1]
if pdfNewlineOpRe.Match(stream[pos:matchStart]) {
pendingNewline = true
}
operand := stream[pos+loc[2] : pos+loc[3]]
var call strings.Builder
for _, lit := range pdfLiteralRe.FindAll(operand, -1) {
call.WriteString(pdfUnescapeLiteral(lit[1 : len(lit)-1]))
}
if text := call.String(); text != "" {
if sb.Len() > 0 {
if pendingNewline {
sb.WriteByte('\n')
} else {
sb.WriteByte(' ')
}
}
pendingNewline = false
sb.WriteString(text)
}
pos = matchEnd
}
return strings.TrimSpace(sb.String())
}
// pdfUnescapeLiteral decodes a PDF literal string's backslash escapes
// (\n \r \t \( \) \\ and octal \ddd) and treats every other byte as
// raw Latin-1 — correct for the common case (a simple, non-custom
// font encoding) and the best this package can do without parsing the
// PDF's font /Encoding and /ToUnicode tables.
func pdfUnescapeLiteral(b []byte) string {
var sb strings.Builder
for i := 0; i < len(b); i++ {
c := b[i]
if c != '\\' || i == len(b)-1 {
sb.WriteByte(c)
continue
}
i++
switch n := b[i]; {
case n == 'n':
sb.WriteByte('\n')
case n == 'r':
sb.WriteByte('\r')
case n == 't':
sb.WriteByte('\t')
case n == 'b' || n == 'f':
// backspace/form-feed: not meaningful in extracted text
case n == '(' || n == ')' || n == '\\':
sb.WriteByte(n)
case n == '\n':
// backslash-newline is a line continuation, not a literal char
case n >= '0' && n <= '7':
j := i
for j < len(b) && j < i+3 && b[j] >= '0' && b[j] <= '7' {
j++
}
if v, err := strconv.ParseInt(string(b[i:j]), 8, 32); err == nil {
sb.WriteByte(byte(v))
}
i = j - 1
default:
sb.WriteByte(n)
}
}
return sb.String()
}

90
pkg/officekit/pdf_test.go Normal file
View File

@@ -0,0 +1,90 @@
package officekit
import (
"bytes"
"compress/zlib"
"strings"
"testing"
)
// buildMinimalPdf wraps one FlateDecode-compressed content stream in
// just enough surrounding PDF syntax for ReadPdfText's regex-based
// scanner to find it — a %PDF- header and one `N 0 obj << ... /Filter
// /FlateDecode >> stream ... endstream endobj` object. Not a complete,
// schema-valid PDF (no xref table, no page tree), but ReadPdfText
// never looks at either of those, so this is a faithful enough fixture
// for exercising it without needing a real binary PDF checked into
// the repo.
func buildMinimalPdf(t *testing.T, content string) []byte {
t.Helper()
var compressed bytes.Buffer
zw := zlib.NewWriter(&compressed)
if _, err := zw.Write([]byte(content)); err != nil {
t.Fatalf("zlib write: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("zlib close: %v", err)
}
var buf bytes.Buffer
buf.WriteString("%PDF-1.4\n")
buf.WriteString("4 0 obj\n<< /Length 999 /Filter /FlateDecode >>\nstream\n")
buf.Write(compressed.Bytes())
buf.WriteString("\nendstream\nendobj\n%%EOF")
return buf.Bytes()
}
func TestReadPdfTextBasic(t *testing.T) {
data := buildMinimalPdf(t, "BT /F1 12 Tf 72 712 Td (Hello World) Tj ET")
text, err := ReadPdfText(data)
if err != nil {
t.Fatalf("ReadPdfText: %v", err)
}
if text != "Hello World" {
t.Errorf("got %q, want %q", text, "Hello World")
}
}
func TestReadPdfTextLineBreaksAndEscapes(t *testing.T) {
content := "BT /F1 12 Tf 72 712 Td (Line one \\(escaped\\)) Tj\n72 700 Td (Line two) Tj ET"
data := buildMinimalPdf(t, content)
text, err := ReadPdfText(data)
if err != nil {
t.Fatalf("ReadPdfText: %v", err)
}
if !strings.Contains(text, "Line one (escaped)") {
t.Errorf("expected unescaped parens in %q", text)
}
if !strings.Contains(text, "Line two") {
t.Errorf("expected second Td'd line's text in %q", text)
}
if !strings.Contains(text, "\n") {
t.Errorf("expected a newline between Td-separated text runs, got %q", text)
}
}
func TestReadPdfTextTJArrayKerning(t *testing.T) {
data := buildMinimalPdf(t, "BT /F1 12 Tf 72 712 Td [(Hel)-20(lo)20( world)] TJ ET")
text, err := ReadPdfText(data)
if err != nil {
t.Fatalf("ReadPdfText: %v", err)
}
if text != "Hello world" {
t.Errorf("got %q, want %q (TJ kerning numbers must not become spaces)", text, "Hello world")
}
}
func TestReadPdfTextRejectsNonPdf(t *testing.T) {
if _, err := ReadPdfText([]byte("just some ordinary bytes, not a PDF")); err == nil {
t.Error("expected an error for input with no %PDF- header")
}
}
func TestReadPdfTextNoShownTextOperators(t *testing.T) {
// A validly-structured FlateDecode stream (so the inflate step
// succeeds) with no Tj/TJ/'/" calls at all — e.g. a pure image draw.
data := buildMinimalPdf(t, "q 100 0 0 100 0 0 cm /Im0 Do Q")
if _, err := ReadPdfText(data); err == nil {
t.Error("expected an error when the stream has no text-showing operators")
}
}

376
pkg/officekit/pptx.go Normal file
View File

@@ -0,0 +1,376 @@
package officekit
import (
"bytes"
"encoding/xml"
"fmt"
"strings"
)
// Slide is one slide's content, as the model supplies it to
// create_presentation/edit_presentation: a title and a flat list of
// bullet lines. Deliberately this simple — no layouts, no inherited
// placeholders, no raw DrawingML — so the model never authors
// PresentationML either; every slide is rendered as two independent,
// absolutely-positioned text boxes (title, body) that don't need to
// resolve against the slide layout/master's placeholder scheme.
type Slide struct {
Title string
Bullets []string
}
// --- read side: typed XML for parsing a slide's shape tree back out
// (ReadPptxSlides). Every struct tag below is a plain, unprefixed
// local name with no namespace component — PresentationML genuinely
// needs several simultaneous namespaces (p: for presentation elements,
// a: for drawing elements), so unlike docx's single-namespace case we
// can't lean on one default namespace to cover everything. Instead we
// rely on a different, equally standard encoding/xml behavior: a field
// tag with no namespace component matches an element by local name
// only, regardless of which namespace it actually resolved to. So
// "cSld" matches <p:cSld> and "xfrm" matches <a:xfrm> alike, without
// our Go types needing to know or care which prefix/namespace a given
// element belongs to. (See the longer version of this note on
// wDocument in docx.go — same underlying mechanism, applied here to
// the harder, multi-namespace case.) ---
type pSld struct {
XMLName xml.Name `xml:"sld"`
CSld pCSld `xml:"cSld"`
}
type pCSld struct {
SpTree pSpTree `xml:"spTree"`
}
type pSpTree struct {
Sp []pSp `xml:"sp"`
}
type pCNvPr struct {
Id int `xml:"id,attr"`
Name string `xml:"name,attr"`
}
type pSp struct {
NvSpPr pNvSpPr `xml:"nvSpPr"`
TxBody pTxBody `xml:"txBody"`
}
type pNvSpPr struct {
CNvPr pCNvPr `xml:"cNvPr"`
}
type pTxBody struct {
Paras []pTxPara `xml:"p"`
}
type pTxPara struct {
Run pTxRun `xml:"r"`
}
type pTxRun struct {
Text string `xml:"t"`
}
// --- write side: deliberately NOT built with xml.Marshal. PresentationML's
// several simultaneous namespaces mean a struct-tag-driven Marshal would
// need Go to auto-assign namespace prefixes, which is both unpredictable
// and avoidable here — we fully control this template, so we write the
// real p:/a: prefixes directly and escape every piece of user-supplied
// text through encoding/xml's own escaper (xmlEscapeText), which is the
// same function Marshal itself uses internally for chardata. That keeps
// the "well-formed, correctly-escaped, never touched by the model"
// property from the package doc comment without fighting Go's namespace
// prefix allocation. ---
// xmlEscapeText XML-escapes s for use as element chardata (title and
// bullet text — the only user-supplied strings that end up in a slide).
func xmlEscapeText(s string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(s))
return buf.String()
}
const titleShapeTemplate = `<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Title"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="457200" y="274638"/><a:ext cx="8229600" cy="1143000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="3200" b="1"/><a:t>%s</a:t></a:r></a:p></p:txBody>
</p:sp>`
const bodyParaTemplate = `<a:p><a:pPr><a:buChar char="&#8226;"/></a:pPr><a:r><a:rPr lang="en-US" sz="2000"/><a:t>%s</a:t></a:r></a:p>`
const bodyShapeTemplate = `<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Body"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="457200" y="1600200"/><a:ext cx="8229600" cy="4525963"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/>%s</p:txBody>
</p:sp>`
func titleShape(id int, text string) string {
return fmt.Sprintf(titleShapeTemplate, id, xmlEscapeText(text))
}
func bodyShape(id int, bullets []string) string {
var paras strings.Builder
for _, b := range bullets {
fmt.Fprintf(&paras, bodyParaTemplate, xmlEscapeText(b))
}
return fmt.Sprintf(bodyShapeTemplate, id, paras.String())
}
func slideXML(s Slide) ([]byte, error) {
var shapes strings.Builder
if s.Title != "" {
shapes.WriteString(titleShape(2, s.Title))
}
if len(s.Bullets) > 0 {
shapes.WriteString(bodyShape(3, s.Bullets))
}
out := xml.Header + `<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>` + shapes.String() + `</p:spTree></p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>`
return []byte(out), nil
}
func slidePath(n int) string { return fmt.Sprintf("ppt/slides/slide%d.xml", n) }
func slideRelsPath(n int) string { return fmt.Sprintf("ppt/slides/_rels/slide%d.xml.rels", n) }
const slideRelsBody = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
</Relationships>`
// BuildPptx renders slides into a complete, valid .pptx file.
func BuildPptx(slides []Slide) ([]byte, error) {
if len(slides) == 0 {
slides = []Slide{{}}
}
parts := []zipPart{
{Name: "[Content_Types].xml", Body: pptxContentTypes(len(slides))},
{Name: "_rels/.rels", Body: pptxRootRels},
{Name: "ppt/presentation.xml", Body: pptxPresentationXML(len(slides))},
{Name: "ppt/_rels/presentation.xml.rels", Body: pptxPresentationRels(len(slides))},
{Name: "ppt/slideMasters/slideMaster1.xml", Body: pptxSlideMaster},
{Name: "ppt/slideMasters/_rels/slideMaster1.xml.rels", Body: pptxSlideMasterRels},
{Name: "ppt/slideLayouts/slideLayout1.xml", Body: pptxSlideLayout},
{Name: "ppt/slideLayouts/_rels/slideLayout1.xml.rels", Body: pptxSlideLayoutRels},
{Name: "ppt/theme/theme1.xml", Body: pptxTheme},
{Name: "docProps/core.xml", Body: officeCoreProps},
}
for i, s := range slides {
n := i + 1
sx, err := slideXML(s)
if err != nil {
return nil, err
}
parts = append(parts,
zipPart{Name: slidePath(n), Body: string(sx)},
zipPart{Name: slideRelsPath(n), Body: slideRelsBody},
)
}
return buildZip(parts)
}
// ReadPptxSlides returns every slide's title + bullets, in order, for
// the read_presentation tool. It re-derives slide order and count from
// ppt/presentation.xml's sldIdLst / presentation.xml.rels rather than
// assuming a fixed count, so a file this package wrote (or edited)
// round-trips exactly.
func ReadPptxSlides(data []byte) ([]Slide, error) {
n, err := countSlides(data)
if err != nil {
return nil, err
}
var out []Slide
for i := 1; i <= n; i++ {
raw, ok, err := readZipPart(data, slidePath(i))
if err != nil {
return nil, err
}
if !ok {
continue
}
var sld pSld
if err := xml.Unmarshal(raw, &sld); err != nil {
return nil, fmt.Errorf("officekit: parse %s: %w", slidePath(i), err)
}
s := Slide{}
for _, sp := range sld.CSld.SpTree.Sp {
switch sp.NvSpPr.CNvPr.Name {
case "Title":
if len(sp.TxBody.Paras) > 0 {
s.Title = sp.TxBody.Paras[0].Run.Text
}
case "Body":
for _, p := range sp.TxBody.Paras {
s.Bullets = append(s.Bullets, p.Run.Text)
}
}
}
out = append(out, s)
}
return out, nil
}
// countSlides counts how many ppt/slides/slideN.xml parts a .pptx
// actually contains by probing sequentially (simplest reliable
// approach given this package writes slides as a dense 1..n sequence
// and never deletes from the middle).
func countSlides(data []byte) (int, error) {
n := 0
for {
_, ok, err := readZipPart(data, slidePath(n+1))
if err != nil {
return 0, err
}
if !ok {
break
}
n++
}
return n, nil
}
// AppendPptxSlide adds one new slide at the end and returns the new
// file bytes, keeping [Content_Types].xml, presentation.xml and
// presentation.xml.rels in sync with the new slide count.
func AppendPptxSlide(data []byte, s Slide) ([]byte, error) {
n, err := countSlides(data)
if err != nil {
return nil, err
}
newN := n + 1
sx, err := slideXML(s)
if err != nil {
return nil, err
}
out, err := addZipParts(data, []zipPart{
{Name: slidePath(newN), Body: string(sx)},
{Name: slideRelsPath(newN), Body: slideRelsBody},
})
if err != nil {
return nil, err
}
out, err = replaceZipPart(out, "[Content_Types].xml", []byte(pptxContentTypes(newN)))
if err != nil {
return nil, err
}
out, err = replaceZipPart(out, "ppt/presentation.xml", []byte(pptxPresentationXML(newN)))
if err != nil {
return nil, err
}
return replaceZipPart(out, "ppt/_rels/presentation.xml.rels", []byte(pptxPresentationRels(newN)))
}
// UpdatePptxSlide replaces one existing slide's content (1-based
// index) in place.
func UpdatePptxSlide(data []byte, index int, s Slide) ([]byte, error) {
n, err := countSlides(data)
if err != nil {
return nil, err
}
if index < 1 || index > n {
return nil, fmt.Errorf("officekit: slide %d out of range (presentation has %d slide(s))", index, n)
}
sx, err := slideXML(s)
if err != nil {
return nil, err
}
return replaceZipPart(data, slidePath(index), []byte(sx))
}
func pptxContentTypes(n int) string {
var overrides strings.Builder
for i := 1; i <= n; i++ {
fmt.Fprintf(&overrides, `<Override PartName="/%s" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`, slidePath(i))
}
return xml.Header + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
` + overrides.String() + `</Types>`
}
func pptxPresentationXML(n int) string {
var ids strings.Builder
for i := 1; i <= n; i++ {
fmt.Fprintf(&ids, `<p:sldId id="%d" r:id="rId%d"/>`, 255+i, i+1)
}
return xml.Header + `<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
<p:sldIdLst>` + ids.String() + `</p:sldIdLst>
<p:sldSz cx="9144000" cy="6858000"/>
<p:notesSz cx="6858000" cy="9144000"/>
</p:presentation>`
}
func pptxPresentationRels(n int) string {
var rels strings.Builder
for i := 1; i <= n; i++ {
fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide%d.xml"/>`, i+1, i)
}
return xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
` + rels.String() + `</Relationships>`
}
const pptxRootRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
</Relationships>`
const pptxSlideMasterRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
</Relationships>`
const pptxSlideLayoutRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
</Relationships>`
const pptxSlideMaster = xml.Header + `<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>
<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>
<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>
<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
</p:sldMaster>`
const pptxSlideLayout = xml.Header + `<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1">
<p:cSld name="Blank">
<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sldLayout>`
const pptxTheme = xml.Header + `<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="enkly">
<a:themeElements>
<a:clrScheme name="enkly">
<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
<a:dk2><a:srgbClr val="44546A"/></a:dk2>
<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
<a:accent1><a:srgbClr val="4472C4"/></a:accent1>
<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
<a:accent4><a:srgbClr val="FFC000"/></a:accent4>
<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>
<a:accent6><a:srgbClr val="70AD47"/></a:accent6>
<a:hlink><a:srgbClr val="0563C1"/></a:hlink>
<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
</a:clrScheme>
<a:fontScheme name="enkly">
<a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>
<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>
</a:fontScheme>
<a:fmtScheme name="enkly">
<a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst>
<a:lnStyleLst><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst>
<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>
<a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst>
</a:fmtScheme>
</a:themeElements>
</a:theme>`

302
pkg/officekit/redline.go Normal file
View File

@@ -0,0 +1,302 @@
// redline.go adds lightweight "track changes" (redlining) support on
// top of the plain append/replace operations in docx.go: the same
// kind of edit — change this document's text — but recorded as a
// Word-reviewable <w:ins>/<w:del> pair instead of a silent in-place
// rewrite, plus accept/reject to resolve those changes afterward.
// Word, LibreOffice and Google Docs (on import) all read and display
// these the same way: Review > Track Changes.
//
// Scope, deliberately: a tracked replace marks the WHOLE run that
// contained a match as changed (old text struck through, new text
// inserted next to it) rather than computing a minimal character-level
// diff within the run — the same "whole unit, not a sub-span"
// tradeoff ReplaceDocxText already makes implicitly by working at the
// <w:t> level (see its doc comment in docx.go). A run in a real
// document is usually already one coherent span of text in one
// formatting (Word splits runs on formatting changes, not mid-word),
// so in practice this reads as a natural redline rather than an
// oversized strikethrough.
package officekit
import (
"bytes"
"encoding/xml"
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
// wRunRe matches one complete <w:r>...</w:r> run — its opening tag,
// any attributes, its full inner content, and its closing tag — so a
// tracked replace can pull the run's formatting (its leading <w:rPr>,
// if any) out and copy it into both halves of the del/ins pair it
// produces. Safe as a non-nested regex because OOXML's schema never
// allows a <w:r> to contain another <w:r> (a run is the schema's
// smallest text-formatting unit, not a container for other runs).
var wRunRe = regexp.MustCompile(`(?s)<((?:[A-Za-z][\w.-]*:)?r)(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?r>`)
// wRPrLeadingRe pulls a run's leading <w:rPr>...</w:rPr> block (if
// any) off the front of its inner content.
var wRPrLeadingRe = regexp.MustCompile(`(?s)^\s*(<(?:[A-Za-z][\w.-]*:)?rPr>.*?</(?:[A-Za-z][\w.-]*:)?rPr>)`)
// wInsBlockRe / wDelBlockRe match one complete <w:ins>/<w:del> block,
// capturing its opening tag's attributes and its inner content
// separately — AcceptAllDocxChanges, RejectAllDocxChanges and
// ReadDocxRevisions all work by matching one or the other and either
// keeping, dropping, or reporting on what's inside.
var wInsBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?ins(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?ins>`)
var wDelBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?del(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?del>`)
// wDelTextElementRe matches one <w:delText>...</w:delText> element,
// the deletion-tracking equivalent of <w:t> used only inside <w:del>
// runs — RejectAllDocxChanges turns these back into ordinary <w:t>
// elements so the restored (un-deleted) text renders normally again.
var wDelTextElementRe = regexp.MustCompile(`(?s)(<(?:[A-Za-z][\w.-]*:)?delText(?:\s[^>]*)?>)(.*?)(</(?:[A-Za-z][\w.-]*:)?delText>)`)
// changeAuthorRe pulls the author="..." (optionally namespace-
// prefixed) attribute value out of a <w:ins>/<w:del> opening tag's
// captured attribute string.
var changeAuthorRe = regexp.MustCompile(`(?:^|\s)(?:[\w.-]+:)?author="([^"]*)"`)
// revisionIDRe finds every existing id="N" attribute in a
// document.xml, so new tracked changes can continue numbering from
// the highest ID already present rather than risking a collision with
// revisions from an earlier edit (an uploaded document, or an earlier
// tool call in the same session).
var revisionIDRe = regexp.MustCompile(`id="(\d+)"`)
func nextRevisionIDFrom(doc []byte) int {
max := 0
for _, m := range revisionIDRe.FindAllSubmatch(doc, -1) {
if n, err := strconv.Atoi(string(m[1])); err == nil && n > max {
max = n
}
}
return max + 1
}
// revisionDate is w:date's expected format (ISO 8601, UTC, second
// precision) — the same convention Word itself writes.
func revisionDate() string {
return time.Now().UTC().Format("2006-01-02T15:04:05Z")
}
func xmlEscapeAttrVal(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "<", "&lt;")
return s
}
// ReplaceDocxTextTracked is ReplaceDocxText's tracked-changes sibling:
// instead of silently rewriting matching text runs in place, it turns
// each matching run into a <w:del>/<w:ins> pair recording who changed
// it and when, exactly as if a human reviewer had made the edit with
// Track Changes switched on in Word. See this file's package doc for
// the whole-run-not-sub-span scope this operates at. author defaults
// to "enkly agent" if empty.
func ReplaceDocxTextTracked(data []byte, find, replace, author string) ([]byte, int, error) {
if author == "" {
author = "enkly agent"
}
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return nil, 0, err
}
if !ok {
return nil, 0, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
nextID := nextRevisionIDFrom(raw)
date := revisionDate()
changed := 0
newDoc := wRunRe.ReplaceAllFunc(raw, func(match []byte) []byte {
g := wRunRe.FindSubmatch(match)
inner := g[3]
tm := wTextElementRe.FindSubmatch(inner)
if tm == nil {
return match // a tab, break, or field run — no <w:t> to compare against
}
oldText := xmlUnescapeText(tm[2])
if !strings.Contains(oldText, find) {
return match
}
newText := strings.ReplaceAll(oldText, find, replace)
changed++
rPr := ""
if m := wRPrLeadingRe.FindSubmatch(inner); m != nil {
rPr = string(m[1])
}
delID, insID := nextID, nextID+1
nextID += 2
var sb strings.Builder
fmt.Fprintf(&sb, `<del id="%d" author="%s" date="%s"><r>%s<delText xml:space="preserve">%s</delText></r></del>`,
delID, xmlEscapeAttrVal(author), date, rPr, xmlEscapeText(oldText))
fmt.Fprintf(&sb, `<ins id="%d" author="%s" date="%s"><r>%s<t xml:space="preserve">%s</t></r></ins>`,
insID, xmlEscapeAttrVal(author), date, rPr, xmlEscapeText(newText))
return []byte(sb.String())
})
if changed == 0 {
return nil, 0, fmt.Errorf("officekit: %q not found in any text run", find)
}
out, err := replaceZipPart(data, docxDocumentPart, newDoc)
return out, changed, err
}
// wIns is a <w:ins> element's content: the tracked-insertion wrapper
// AppendDocxParagraphTracked puts around a brand-new paragraph's run,
// mirroring ReplaceDocxTextTracked's own <ins id author date><r>...
// shape above but built with encoding/xml (like the rest of a freshly
// generated paragraph fragment) rather than string formatting, since
// here — unlike a splice into arbitrary existing XML — the whole
// fragment is this package's own output.
type wIns struct {
ID int `xml:"id,attr"`
Author string `xml:"author,attr"`
Date string `xml:"date,attr"`
Run wRun `xml:"r"`
}
// wInsParagraphFragment is wParagraphFragment's (docx.go)
// tracked-insert counterpart: same paragraph content, but with its
// run wrapped in <w:ins> so a reviewer sees the whole new paragraph as
// a pending insertion rather than it silently becoming part of the
// document.
type wInsParagraphFragment struct {
XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main p"`
PPr *wPPr `xml:"pPr,omitempty"`
Ins wIns `xml:"ins"`
}
// AppendDocxParagraphTracked is AppendDocxParagraph's tracked-changes
// sibling: the new paragraph is spliced in exactly the same way (see
// AppendDocxParagraph's doc comment in docx.go), but its run is
// wrapped in <w:ins>, so it shows up in Word as a pending insertion
// (Review > Track Changes) instead of silently becoming part of the
// document. author defaults to "enkly agent" if empty.
func AppendDocxParagraphTracked(data []byte, p Paragraph, author string) ([]byte, error) {
if author == "" {
author = "enkly agent"
}
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
wp := toWParagraph(p)
frag := wInsParagraphFragment{
PPr: wp.PPr,
Ins: wIns{ID: nextRevisionIDFrom(raw), Author: author, Date: revisionDate(), Run: wp.Run},
}
paraXML, err := xml.Marshal(frag)
if err != nil {
return nil, fmt.Errorf("officekit: marshal tracked paragraph: %w", err)
}
at := bodyInsertionPoint(raw)
if at < 0 {
return nil, fmt.Errorf("officekit: %s has no <w:body> to append to (not a valid .docx)", docxDocumentPart)
}
newDoc := make([]byte, 0, len(raw)+len(paraXML))
newDoc = append(newDoc, raw[:at]...)
newDoc = append(newDoc, paraXML...)
newDoc = append(newDoc, raw[at:]...)
return replaceZipPart(data, docxDocumentPart, newDoc)
}
// AcceptAllDocxChanges resolves every tracked change in the document
// the way Word's "Accept All Changes" does: deleted text (<w:del>) is
// removed for good, and inserted text (<w:ins>) becomes ordinary,
// no-longer-marked content.
func AcceptAllDocxChanges(data []byte) ([]byte, error) {
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
newDoc := wDelBlockRe.ReplaceAll(raw, nil)
newDoc = wInsBlockRe.ReplaceAllFunc(newDoc, func(m []byte) []byte {
return wInsBlockRe.FindSubmatch(m)[2]
})
return replaceZipPart(data, docxDocumentPart, newDoc)
}
// RejectAllDocxChanges resolves every tracked change the way Word's
// "Reject All Changes" does: inserted text (<w:ins>) is discarded, and
// deleted text (<w:del>) is restored as ordinary content.
func RejectAllDocxChanges(data []byte) ([]byte, error) {
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
newDoc := wInsBlockRe.ReplaceAll(raw, nil)
newDoc = wDelBlockRe.ReplaceAllFunc(newDoc, func(m []byte) []byte {
inner := wDelBlockRe.FindSubmatch(m)[2]
// delText -> t, so the restored text renders as normal content
// rather than staying tagged as a (now orphaned) deletion.
return wDelTextElementRe.ReplaceAllFunc(inner, func(tm []byte) []byte {
g := wDelTextElementRe.FindSubmatch(tm)
open := bytes.Replace(g[1], []byte("delText"), []byte("t"), 1)
closeTag := bytes.Replace(g[3], []byte("delText"), []byte("t"), 1)
out := make([]byte, 0, len(open)+len(g[2])+len(closeTag))
out = append(out, open...)
out = append(out, g[2]...)
out = append(out, closeTag...)
return out
})
})
return replaceZipPart(data, docxDocumentPart, newDoc)
}
// wChangeBlockRe matches one complete <w:ins>...</w:ins> or
// <w:del>...</w:del> block, capturing which kind it is, its opening
// tag's attributes, and its inner content — ReadDocxRevisions' single
// pass over the document.
var wChangeBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?(ins|del)(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?(?:ins|del)>`)
// ReadDocxRevisions lists every tracked change (insertion or deletion)
// currently in the document — author and the affected text, in
// document order — so a caller can review what Track Changes has
// recorded before deciding whether to accept or reject it. Returns
// ("", nil) if the document has no tracked changes at all.
func ReadDocxRevisions(data []byte) (string, error) {
raw, ok, err := readZipPart(data, docxDocumentPart)
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
}
matches := wChangeBlockRe.FindAllSubmatch(raw, -1)
if len(matches) == 0 {
return "", nil
}
var sb strings.Builder
for i, m := range matches {
kind, attrs, inner := string(m[1]), m[2], m[3]
author := "unknown"
if am := changeAuthorRe.FindSubmatch(attrs); am != nil {
author = string(am[1])
}
label, textRe := "inserted", wTextElementRe
if kind == "del" {
label, textRe = "deleted", wDelTextElementRe
}
var text strings.Builder
for _, tm := range textRe.FindAllSubmatch(inner, -1) {
text.WriteString(xmlUnescapeText(tm[2]))
}
fmt.Fprintf(&sb, "%d. %s by %s: %q\n", i+1, label, author, text.String())
}
return strings.TrimRight(sb.String(), "\n"), nil
}

View File

@@ -0,0 +1,114 @@
package officekit
import (
"strings"
"testing"
)
func TestReplaceDocxTextTrackedRoundTrip(t *testing.T) {
data := minimalWordDoc(t)
out, n, err := ReplaceDocxTextTracked(data, "cell one text", "updated cell text", "Reviewer A")
if err != nil {
t.Fatalf("ReplaceDocxTextTracked: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 run changed, got %d", n)
}
// Plain read-back should show the NEW text (deleted text is review
// chrome, not live content) and still see the untouched paragraph.
text, err := ReadDocxText(out)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
if !strings.Contains(text, "updated cell text") {
t.Errorf("expected new text in read-back, got: %s", text)
}
if !strings.Contains(text, "Intro paragraph.") {
t.Errorf("unrelated paragraph lost: %s", text)
}
revisions, err := ReadDocxRevisions(out)
if err != nil {
t.Fatalf("ReadDocxRevisions: %v", err)
}
for _, want := range []string{"Reviewer A", "cell one text", "updated cell text"} {
if !strings.Contains(revisions, want) {
t.Errorf("expected revisions summary to contain %q, got:\n%s", want, revisions)
}
}
raw, ok, err := readZipPart(out, docxDocumentPart)
if err != nil || !ok {
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
}
body := string(raw)
if !strings.Contains(body, "<del ") || !strings.Contains(body, "<ins ") {
t.Errorf("expected <del>/<ins> elements in document.xml, got:\n%s", body)
}
}
func TestAppendDocxParagraphTrackedThenAccept(t *testing.T) {
data := minimalWordDoc(t)
out, err := AppendDocxParagraphTracked(data, Paragraph{Text: "Pending new line."}, "")
if err != nil {
t.Fatalf("AppendDocxParagraphTracked: %v", err)
}
revisions, err := ReadDocxRevisions(out)
if err != nil {
t.Fatalf("ReadDocxRevisions: %v", err)
}
if !strings.Contains(revisions, "enkly agent") || !strings.Contains(revisions, "Pending new line.") {
t.Errorf("expected default author + inserted text in revisions, got:\n%s", revisions)
}
accepted, err := AcceptAllDocxChanges(out)
if err != nil {
t.Fatalf("AcceptAllDocxChanges: %v", err)
}
text, err := ReadDocxText(accepted)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
if !strings.Contains(text, "Pending new line.") {
t.Errorf("expected accepted insertion to survive as plain text, got: %s", text)
}
if noRev, err := ReadDocxRevisions(accepted); err != nil || noRev != "" {
t.Errorf("expected no revisions left after accept, got %q (err=%v)", noRev, err)
}
}
func TestReplaceDocxTextTrackedThenReject(t *testing.T) {
data := minimalWordDoc(t)
out, _, err := ReplaceDocxTextTracked(data, "Intro paragraph.", "Rewritten intro.", "Reviewer B")
if err != nil {
t.Fatalf("ReplaceDocxTextTracked: %v", err)
}
rejected, err := RejectAllDocxChanges(out)
if err != nil {
t.Fatalf("RejectAllDocxChanges: %v", err)
}
text, err := ReadDocxText(rejected)
if err != nil {
t.Fatalf("ReadDocxText: %v", err)
}
if !strings.Contains(text, "Intro paragraph.") {
t.Errorf("expected original text restored after reject, got: %s", text)
}
if strings.Contains(text, "Rewritten intro.") {
t.Errorf("rejected insertion should not survive, got: %s", text)
}
if noRev, err := ReadDocxRevisions(rejected); err != nil || noRev != "" {
t.Errorf("expected no revisions left after reject, got %q (err=%v)", noRev, err)
}
}
func TestReadDocxRevisionsEmptyWhenNoChanges(t *testing.T) {
revisions, err := ReadDocxRevisions(minimalWordDoc(t))
if err != nil {
t.Fatalf("ReadDocxRevisions: %v", err)
}
if revisions != "" {
t.Errorf("expected no revisions in a plain document, got: %s", revisions)
}
}

505
pkg/officekit/xlsx.go Normal file
View File

@@ -0,0 +1,505 @@
package officekit
import (
"encoding/xml"
"fmt"
"sort"
"strconv"
"strings"
)
// Sheet is one worksheet's data, as the model supplies it to
// create_spreadsheet: a name and a grid of string cell values (numbers
// are written as plain decimal strings; anything that parses as a
// number is stored as a numeric cell so Excel treats it as one).
type Sheet struct {
Name string
Rows [][]string
}
const xlsxWorkbookPart = "xl/workbook.xml"
const xlsxWorkbookRelsPart = "xl/_rels/workbook.xml.rels"
// xlsxSheetPart returns the conventional worksheet part path for the
// Nth (1-based) sheet BuildXlsx writes — sheet1.xml, sheet2.xml, ...
func xlsxSheetPart(n int) string { return fmt.Sprintf("xl/worksheets/sheet%d.xml", n) }
// --- typed XML for the parts BuildXlsx/edit operations actually
// generate from user-supplied content (worksheet XML, plus the small
// workbook/rels indirection needed to support more than one sheet);
// every other part is static boilerplate with no user content. ---
type sWorksheet struct {
XMLName xml.Name `xml:"worksheet"`
Xmlns string `xml:"xmlns,attr"`
SheetData sSheetData `xml:"sheetData"`
}
type sSheetData struct {
Rows []sRow `xml:"row"`
}
type sRow struct {
R int `xml:"r,attr"`
Cells []sCell `xml:"c"`
}
// sCell's field order (after the r/t attrs) follows OOXML's CT_Cell
// schema sequence — f (formula), v (value), is (inline string) — so a
// formula cell with a cached value (f + v both set, as real Excel
// always writes them) round-trips as a schema-valid element.
type sCell struct {
R string `xml:"r,attr"`
Type string `xml:"t,attr,omitempty"` // "inlineStr" for text; omitted (numeric/formula) otherwise
Formula *string `xml:"f,omitempty"` // formula cells ("=SUM(...)" minus the leading "=")
Value *string `xml:"v,omitempty"` // numeric cells, and a formula's last-known/cached result
Is *sIs `xml:"is,omitempty"` // inline-string cells
}
type sIs struct {
T string `xml:"t"`
}
// sWorkbook is the minimal shape of xl/workbook.xml this package
// cares about: just enough to resolve a sheet name/1-based index to
// its relationship id, so ReadXlsxCells/SetXlsxCells can find the
// right worksheet part even in a workbook with more than one sheet
// (including ones not authored by BuildXlsx, e.g. a user upload).
type sWorkbook struct {
XMLName xml.Name `xml:"workbook"`
Sheets []sWbName `xml:"sheets>sheet"`
}
type sWbName struct {
Name string `xml:"name,attr"`
RID string `xml:"id,attr"` // r:id — encoding/xml matches the local name regardless of the "r" namespace prefix.
}
// sRelationships is the minimal shape of a .rels part this package
// needs: a relationship id -> target path.
type sRelationships struct {
XMLName xml.Name `xml:"Relationships"`
Rel []sRelationship `xml:"Relationship"`
}
type sRelationship struct {
ID string `xml:"Id,attr"`
Target string `xml:"Target,attr"`
}
// colLetter converts a 0-based column index to its spreadsheet letter
// (0 -> A, 25 -> Z, 26 -> AA, ...).
func colLetter(i int) string {
s := ""
for {
s = string(rune('A'+(i%26))) + s
i = i/26 - 1
if i < 0 {
break
}
}
return s
}
func isNumeric(s string) bool {
if s == "" {
return false
}
_, err := strconv.ParseFloat(s, 64)
return err == nil
}
func rowsToSheetXML(rows [][]string) ([]byte, error) {
sd := sSheetData{}
for ri, row := range rows {
r := sRow{R: ri + 1}
for ci, val := range row {
cell := sCell{R: colLetter(ci) + strconv.Itoa(ri+1)}
setCellValue(&cell, val)
r.Cells = append(r.Cells, cell)
}
sd.Rows = append(sd.Rows, r)
}
ws := sWorksheet{Xmlns: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", SheetData: sd}
body, err := xml.Marshal(ws)
if err != nil {
return nil, fmt.Errorf("officekit: marshal worksheet xml: %w", err)
}
return []byte(xml.Header + string(body)), nil
}
// BuildXlsx renders one or more sheets into a complete, valid .xlsx
// workbook — every sheet given is written as its own worksheet part
// and listed in xl/workbook.xml in order, each with a matching
// relationship in xl/_rels/workbook.xml.rels. An unnamed sheet gets
// the conventional "SheetN" name.
func BuildXlsx(sheets []Sheet) ([]byte, error) {
if len(sheets) == 0 {
sheets = []Sheet{{Name: "Sheet1"}}
}
parts := []zipPart{
{Name: "_rels/.rels", Body: xlsxRootRels},
{Name: "xl/styles.xml", Body: xlsxStyles},
{Name: "docProps/core.xml", Body: officeCoreProps},
}
var sheetEntries, relEntries, overrides strings.Builder
for i, sh := range sheets {
idx := i + 1
name := sh.Name
if name == "" {
name = fmt.Sprintf("Sheet%d", idx)
}
sheetXML, err := rowsToSheetXML(sh.Rows)
if err != nil {
return nil, err
}
parts = append(parts, zipPart{Name: xlsxSheetPart(idx), Body: string(sheetXML)})
fmt.Fprintf(&sheetEntries, `<sheet name="%s" sheetId="%d" r:id="rId%d"/>`, xmlEscapeAttr(name), idx, idx)
fmt.Fprintf(&relEntries, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet%d.xml"/>`, idx, idx)
fmt.Fprintf(&overrides, `<Override PartName="/xl/worksheets/sheet%d.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`, idx)
}
// The styles relationship id just needs to be distinct from every
// sheet's rId above; one past the last sheet always is.
fmt.Fprintf(&relEntries, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`, len(sheets)+1)
workbookXML := xml.Header + `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>` +
sheetEntries.String() + `</sheets></workbook>`
workbookRels := xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
relEntries.String() + `</Relationships>`
contentTypes := xml.Header + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
`<Default Extension="xml" ContentType="application/xml"/>` +
`<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
overrides.String() +
`<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>` +
`<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>`
parts = append(parts,
zipPart{Name: "[Content_Types].xml", Body: contentTypes},
zipPart{Name: xlsxWorkbookPart, Body: workbookXML},
zipPart{Name: xlsxWorkbookRelsPart, Body: workbookRels},
)
return buildZip(parts)
}
// xmlEscapeAttr escapes the handful of characters that matter inside
// an XML attribute value. Used only for the places we interpolate
// user text into a hand-written template string (sheet names in
// workbook.xml) rather than through encoding/xml.
func xmlEscapeAttr(s string) string {
r := strings.NewReplacer(`&`, "&amp;", `<`, "&lt;", `>`, "&gt;", `"`, "&quot;")
return r.Replace(s)
}
// ReadXlsxSheetNames returns every sheet name in the workbook, in
// order — used by read_spreadsheet to tell the model what else is in
// a multi-sheet file it only asked to read one sheet of.
func ReadXlsxSheetNames(data []byte) ([]string, error) {
wb, err := readXlsxWorkbook(data)
if err != nil {
return nil, err
}
names := make([]string, len(wb.Sheets))
for i, s := range wb.Sheets {
names[i] = s.Name
}
return names, nil
}
func readXlsxWorkbook(data []byte) (*sWorkbook, error) {
raw, ok, err := readZipPart(data, xlsxWorkbookPart)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("officekit: %s not found (not a valid .xlsx)", xlsxWorkbookPart)
}
var wb sWorkbook
if err := xml.Unmarshal(raw, &wb); err != nil {
return nil, fmt.Errorf("officekit: parse workbook xml: %w", err)
}
return &wb, nil
}
// resolveXlsxSheetPart maps a sheet reference (empty for the first
// sheet, a 1-based index as a string, or a sheet name) to that sheet's
// actual worksheet part path, by reading xl/workbook.xml and
// xl/_rels/workbook.xml.rels — not by assuming sheet1.xml is sheet
// one, since that isn't guaranteed for a file this package didn't
// write itself (a user-uploaded workbook whose sheets were reordered
// or renumbered in Excel). Falls back to the sheet1.xml convention
// only if the workbook's own bookkeeping can't be parsed at all.
func resolveXlsxSheetPart(data []byte, sheetRef string) (partPath, sheetName string, err error) {
wb, err := readXlsxWorkbook(data)
if err != nil || len(wb.Sheets) == 0 {
return xlsxSheetPart(1), "", nil
}
var target *sWbName
switch {
case sheetRef == "":
target = &wb.Sheets[0]
default:
if n, convErr := strconv.Atoi(sheetRef); convErr == nil {
if n < 1 || n > len(wb.Sheets) {
return "", "", fmt.Errorf("officekit: sheet %d out of range (workbook has %d sheet(s))", n, len(wb.Sheets))
}
target = &wb.Sheets[n-1]
} else {
for i := range wb.Sheets {
if strings.EqualFold(wb.Sheets[i].Name, sheetRef) {
target = &wb.Sheets[i]
break
}
}
if target == nil {
return "", "", fmt.Errorf("officekit: no sheet named %q", sheetRef)
}
}
}
relsRaw, ok, err := readZipPart(data, xlsxWorkbookRelsPart)
if err != nil || !ok {
return xlsxSheetPart(1), target.Name, nil
}
var rels sRelationships
if err := xml.Unmarshal(relsRaw, &rels); err != nil {
return xlsxSheetPart(1), target.Name, nil
}
for _, r := range rels.Rel {
if r.ID == target.RID {
t := strings.TrimPrefix(r.Target, "/")
if !strings.HasPrefix(t, "xl/") {
t = "xl/" + t
}
return t, target.Name, nil
}
}
return xlsxSheetPart(1), target.Name, nil
}
// ReadXlsxCells returns one worksheet's cells as a row-major grid of
// display strings, for the read_spreadsheet tool. sheet selects which
// one: empty for the first sheet, a 1-based index ("2"), or a sheet
// name (case-insensitive); the resolved name is returned alongside so
// callers can report it.
func ReadXlsxCells(data []byte, sheet string) ([][]string, string, error) {
partPath, sheetName, err := resolveXlsxSheetPart(data, sheet)
if err != nil {
return nil, "", err
}
raw, ok, err := readZipPart(data, partPath)
if err != nil {
return nil, "", err
}
if !ok {
return nil, "", fmt.Errorf("officekit: %s not found (not a valid .xlsx)", partPath)
}
var ws sWorksheet
if err := xml.Unmarshal(raw, &ws); err != nil {
return nil, "", fmt.Errorf("officekit: parse worksheet xml: %w", err)
}
var out [][]string
for _, row := range ws.SheetData.Rows {
var line []string
for _, c := range row.Cells {
switch {
case c.Is != nil:
line = append(line, c.Is.T)
case c.Value != nil:
// A real Excel-authored formula cell always caches its
// last-computed value alongside the formula itself; show
// that computed result rather than the formula text.
line = append(line, *c.Value)
case c.Formula != nil:
// A formula cell this package just wrote has no cached
// value (nothing here evaluates formulas) — show the
// formula itself rather than a misleading blank.
line = append(line, "="+*c.Formula)
default:
line = append(line, "")
}
}
out = append(out, line)
}
return out, sheetName, nil
}
// SetXlsxCells downloads-parses-modifies-reuploads: applies a sparse
// set of cell writes (keyed by "A1"-style reference) to one worksheet
// (selected the same way as ReadXlsxCells) and returns the new file
// bytes. Cells outside the current grid extend it; existing rows/cells
// are preserved otherwise.
func SetXlsxCells(data []byte, sheet string, writes map[string]string) ([]byte, error) {
partPath, _, err := resolveXlsxSheetPart(data, sheet)
if err != nil {
return nil, err
}
raw, ok, err := readZipPart(data, partPath)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("officekit: %s not found (not a valid .xlsx)", partPath)
}
var ws sWorksheet
if err := xml.Unmarshal(raw, &ws); err != nil {
return nil, fmt.Errorf("officekit: parse worksheet xml: %w", err)
}
// rowByNum maps a row number to its INDEX in ws.SheetData.Rows, not
// a pointer into it. A pointer taken here (&ws.SheetData.Rows[i])
// is only valid for the backing array that exists at the moment
// it's taken; the very next `append` below that grows the slice
// past its capacity allocates a new backing array and copies into
// it, silently stranding any earlier-taken pointer on the old,
// now-orphaned one. Writes applied through a stranded pointer
// still "succeed" from the caller's point of view — no error, no
// panic — but vanish, because they land on memory nothing else
// reads. An index has no such failure mode: ws.SheetData.Rows[idx]
// always re-resolves against whatever backing array the slice
// header currently points to.
rowByNum := map[int]int{}
for i := range ws.SheetData.Rows {
rowByNum[ws.SheetData.Rows[i].R] = i
}
// Writes are applied in ascending (row, column) order rather than
// map iteration order (which Go deliberately randomizes). This
// doesn't affect correctness — sortSheetData below restores final
// order regardless — but it keeps behavior deterministic and easy
// to reason about/debug when a batch of cell writes is logged or
// replayed.
refs := make([]string, 0, len(writes))
for ref := range writes {
refs = append(refs, ref)
}
sort.Slice(refs, func(a, b int) bool {
ra, ca, _ := parseCellRef(refs[a])
rb, cb, _ := parseCellRef(refs[b])
if ra != rb {
return ra < rb
}
return ca < cb
})
for _, ref := range refs {
val := writes[ref]
rowNum, _, err := parseCellRef(ref)
if err != nil {
return nil, err
}
idx, ok := rowByNum[rowNum]
if !ok {
ws.SheetData.Rows = append(ws.SheetData.Rows, sRow{R: rowNum})
idx = len(ws.SheetData.Rows) - 1
rowByNum[rowNum] = idx
}
row := &ws.SheetData.Rows[idx]
replaced := false
for i := range row.Cells {
if row.Cells[i].R == ref {
setCellValue(&row.Cells[i], val)
replaced = true
break
}
}
if !replaced {
c := sCell{R: ref}
setCellValue(&c, val)
row.Cells = append(row.Cells, c)
}
}
// Excel is strict about row/cell order within xl/worksheets/sheetN.xml
// (rows ascending by r, cells within a row ascending by column) —
// writes above can append a row/cell out of order (e.g. writing "A1"
// after "A5" already existed), which a lenient reader shrugs off but
// Excel itself can flag as needing "repair". Restore ascending order
// before marshaling so a sparse, arbitrarily-ordered set of writes
// always produces a schema-valid worksheet.
sortSheetData(&ws.SheetData)
body, err := xml.Marshal(ws)
if err != nil {
return nil, fmt.Errorf("officekit: marshal worksheet xml: %w", err)
}
return replaceZipPart(data, partPath, []byte(xml.Header+string(body)))
}
// sortSheetData reorders rows ascending by row number and, within each
// row, cells ascending by column — the order SetXlsxCells' sparse,
// map-driven writes don't otherwise guarantee.
func sortSheetData(sd *sSheetData) {
sort.Slice(sd.Rows, func(i, j int) bool { return sd.Rows[i].R < sd.Rows[j].R })
for i := range sd.Rows {
cells := sd.Rows[i].Cells
sort.Slice(cells, func(a, b int) bool { return cellColumn(cells[a].R) < cellColumn(cells[b].R) })
}
}
// cellColumn returns a cell reference's 0-based column index (e.g.
// "B12" -> 1), or 0 if ref doesn't parse as one — used only to order
// cells within a row for output, so a malformed ref sorts first rather
// than failing a write this deep into the process.
func cellColumn(ref string) int {
_, col, err := parseCellRef(ref)
if err != nil {
return 0
}
return col
}
// setCellValue classifies val and sets exactly the sCell fields that
// representation needs, clearing the others: a leading "=" (with at
// least one character after it) stores a formula, same convention
// Excel itself uses to distinguish "=SUM(A1:A2)" from the literal text
// "=SUM(A1:A2)"; otherwise a value that parses as a plain number is
// numeric, and everything else is inline text.
func setCellValue(c *sCell, val string) {
switch {
case strings.HasPrefix(val, "=") && len(val) > 1:
f := val[1:]
c.Formula, c.Value, c.Is, c.Type = &f, nil, nil, ""
case isNumeric(val):
v := val
c.Formula, c.Value, c.Is, c.Type = nil, &v, nil, ""
default:
c.Formula, c.Value = nil, nil
c.Is, c.Type = &sIs{T: val}, "inlineStr"
}
}
// parseCellRef splits "B12" into (row=12, col=1). Only used to locate
// a row bucket for SetXlsxCells; the column stays embedded in the "r"
// attribute string as-is.
func parseCellRef(ref string) (row, col int, err error) {
i := 0
for i < len(ref) && ref[i] >= 'A' && ref[i] <= 'Z' {
col = col*26 + int(ref[i]-'A'+1)
i++
}
if i == 0 || i == len(ref) {
return 0, 0, fmt.Errorf("officekit: invalid cell reference %q (expected e.g. \"A1\")", ref)
}
row, err = strconv.Atoi(ref[i:])
if err != nil || row < 1 {
return 0, 0, fmt.Errorf("officekit: invalid cell reference %q (expected e.g. \"A1\")", ref)
}
return row, col - 1, nil
}
const xlsxRootRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
</Relationships>`
const xlsxStyles = xml.Header + `<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
</styleSheet>`

181
pkg/officekit/zipkit.go Normal file
View File

@@ -0,0 +1,181 @@
// Package officekit creates and edits real, native Office Open XML
// files — .docx (Word), .xlsx (Excel), .pptx (PowerPoint) — as plain
// Go structs and byte slices, with no dependency beyond the standard
// library (archive/zip + encoding/xml).
//
// # Why this package exists
//
// enkly's agent talks to a Qwen model through a vLLM OpenAI-compatible
// tool-calling API (pkg/vllmkit). Tool calls and tool results travel
// as structured JSON on that wire (an "arguments" string vLLM's
// tool-call parser extracts from the model's raw generation), not
// wrapped in the XML-ish chat-template delimiters an Ollama deployment
// of this same model would have used — so the specific "XML syntax
// error on line N" failure mode an earlier, Ollama-backed version of
// this service saw when a tool result contained raw angle-bracket
// markup no longer applies verbatim. vLLM's tool-call parser can still
// fail to extract a clean call from the model's output, surfaced
// client-side as vllmkit.IsToolCallParseError (see
// agentrun/agent.go's compactToolHistory doc comment for the retry
// strategy built on top of it) — a different failure mode, but the
// same underlying risk: anything that makes the model's raw output
// harder for its tool-call parser to read is worth avoiding.
//
// The structural fix lives here regardless of transport: the model
// NEVER authors or receives OOXML. It only ever calls a tool with
// plain structured arguments
// (a title, a list of paragraphs, a grid of cell values, a list of
// slide bullets) and gets back a short plain-text summary. This
// package does 100% of the actual XML generation and parsing,
// server-side, using Go's typed XML encoder/decoder (which always
// produces well-formed, correctly-escaped XML) — and the resulting
// bytes travel to/from the workspace service over the existing
// UploadFile/DownloadFile RPCs, which carry raw []byte, not the
// text-oriented ReadFile/WriteFile string fields. No proto changes,
// no binary-over-text encoding, and no XML ever touches the LLM.
package officekit
import (
"archive/zip"
"bytes"
"fmt"
"io"
)
// zipPart is one file inside an OOXML package (which is just a zip
// archive with a prescribed internal layout).
type zipPart struct {
Name string
Body string
}
// buildZip packages parts into an OOXML container. Office is picky
// about [Content_Types].xml being present; beyond that, part order
// doesn't matter to any reader in practice, but we write it first
// anyway since that's the convention most real Office-authored files
// follow.
func buildZip(parts []zipPart) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, p := range parts {
w, err := zw.Create(p.Name)
if err != nil {
return nil, fmt.Errorf("officekit: create %s: %w", p.Name, err)
}
if _, err := w.Write([]byte(p.Body)); err != nil {
return nil, fmt.Errorf("officekit: write %s: %w", p.Name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("officekit: close zip: %w", err)
}
return buf.Bytes(), nil
}
// readZipPart extracts one named part's raw bytes from an OOXML
// package, or returns ok=false if it isn't present.
func readZipPart(data []byte, name string) ([]byte, bool, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, false, fmt.Errorf("officekit: not a valid Office file (not a zip archive): %w", err)
}
for _, f := range zr.File {
if f.Name == name {
rc, err := f.Open()
if err != nil {
return nil, false, fmt.Errorf("officekit: open %s: %w", name, err)
}
defer rc.Close()
var buf bytes.Buffer
if _, err := buf.ReadFrom(rc); err != nil {
return nil, false, fmt.Errorf("officekit: read %s: %w", name, err)
}
return buf.Bytes(), true, nil
}
}
return nil, false, nil
}
// addZipParts returns a copy of an OOXML package with every existing
// part copied through unchanged, plus the given new parts appended
// (used by AppendPptxSlide to add a slideN.xml/slideN.xml.rels pair
// without disturbing anything already in the archive).
func addZipParts(data []byte, newParts []zipPart) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("officekit: not a valid Office file (not a zip archive): %w", err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, f := range zr.File {
w, err := zw.Create(f.Name)
if err != nil {
return nil, fmt.Errorf("officekit: create %s: %w", f.Name, err)
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("officekit: open %s: %w", f.Name, err)
}
_, copyErr := io.Copy(w, rc)
rc.Close()
if copyErr != nil {
return nil, fmt.Errorf("officekit: copy %s: %w", f.Name, copyErr)
}
}
for _, p := range newParts {
w, err := zw.Create(p.Name)
if err != nil {
return nil, fmt.Errorf("officekit: create %s: %w", p.Name, err)
}
if _, err := w.Write([]byte(p.Body)); err != nil {
return nil, fmt.Errorf("officekit: write %s: %w", p.Name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("officekit: close zip: %w", err)
}
return buf.Bytes(), nil
}
// replaceZipPart returns a copy of an OOXML package with one part's
// body replaced (used by the edit_* operations: download, patch one
// XML part, re-upload). Every other part is copied through unchanged.
func replaceZipPart(data []byte, name string, newBody []byte) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("officekit: not a valid Office file (not a zip archive): %w", err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
replaced := false
for _, f := range zr.File {
w, err := zw.Create(f.Name)
if err != nil {
return nil, fmt.Errorf("officekit: create %s: %w", f.Name, err)
}
if f.Name == name {
replaced = true
if _, err := w.Write(newBody); err != nil {
return nil, fmt.Errorf("officekit: write %s: %w", f.Name, err)
}
continue
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("officekit: open %s: %w", f.Name, err)
}
_, copyErr := io.Copy(w, rc)
rc.Close()
if copyErr != nil {
return nil, fmt.Errorf("officekit: copy %s: %w", f.Name, copyErr)
}
}
if !replaced {
return nil, fmt.Errorf("officekit: part %s not found in archive", name)
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("officekit: close zip: %w", err)
}
return buf.Bytes(), nil
}

107
pkg/pgkit/pgkit.go Normal file
View File

@@ -0,0 +1,107 @@
// Package pgkit is the small piece of internal/db/db.go that wasn't
// specific to any one bounded context: connecting with a startup retry
// loop (docker compose's "healthy" and "actually accepting
// connections" can be a beat apart) and applying an idempotent
// schema.sql-style migration under an advisory lock so two replicas of
// the same service starting at once don't race each other.
//
// Each data-owning service (iam, billing, conversation, knowledge) gets
// its own *pgkit.DB over its own database — see each service's
// migrations/0001_init.sql — never a shared one. That split is the
// difference between "microservices" and "one database with several
// front ends" (see ARCHITECTURE.md, "Database-per-service").
package pgkit
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/lib/pq"
)
// Sentinel errors every repository package in this repo returns, so
// application-layer use cases can errors.Is against one shared set
// instead of each service inventing its own (matching internal/db's
// original ErrNotFound/ErrEmailTaken/ErrInvalidToken).
var (
ErrNotFound = errors.New("pgkit: not found")
ErrAlreadyTaken = errors.New("pgkit: unique constraint violated")
ErrInvalid = errors.New("pgkit: invalid or expired")
)
// DB wraps *sql.DB exactly as internal/db.DB did.
type DB struct {
*sql.DB
}
// Open connects to Postgres and retries pinging it for up to ~30s.
func Open(dsn string) (*DB, error) {
sqlDB, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("pgkit: open: %w", err)
}
// Pool sizing: each microservice is still a single logical writer
// per table, same reasoning as the monolith's own comment here —
// a handful of connections covers concurrent gRPC calls without
// exhausting Postgres' default max_connections (100) once you
// multiply by four data-owning services.
sqlDB.SetMaxOpenConns(20)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
var lastErr error
deadline := time.Now().Add(30 * time.Second)
for {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
lastErr = sqlDB.PingContext(ctx)
cancel()
if lastErr == nil {
return &DB{sqlDB}, nil
}
if time.Now().After(deadline) {
_ = sqlDB.Close()
return nil, fmt.Errorf("pgkit: ping (gave up after 30s): %w", lastErr)
}
time.Sleep(time.Second)
}
}
// migrationLockID is an arbitrary constant used with pg_advisory_lock.
// Shared across services since each connects to its own database (the
// lock is scoped per-database by Postgres), so there's no cross-service
// collision risk in reusing one constant.
const migrationLockID = 7_402_113_915
// Migrate applies schema (expected to be idempotent — CREATE ... IF NOT
// EXISTS throughout, as with the monolith's schema.sql) under an
// advisory lock.
func (d *DB) Migrate(ctx context.Context, schema string) error {
conn, err := d.Conn(ctx)
if err != nil {
return fmt.Errorf("pgkit: migrate: acquire connection: %w", err)
}
defer conn.Close()
if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1)`, migrationLockID); err != nil {
return fmt.Errorf("pgkit: migrate: lock: %w", err)
}
defer func() {
_, _ = conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock($1)`, migrationLockID)
}()
if _, err := conn.ExecContext(ctx, schema); err != nil {
return fmt.Errorf("pgkit: migrate: apply schema: %w", err)
}
return nil
}
// IsUniqueViolation reports whether err is Postgres' unique_violation (23505).
func IsUniqueViolation(err error) bool {
var pqErr *pq.Error
return errors.As(err, &pqErr) && pqErr.Code == "23505"
}

359
pkg/renderkit/highlight.go Normal file
View File

@@ -0,0 +1,359 @@
package renderkit
import (
stdhtml "html"
"strings"
"unicode/utf8"
)
// A small, dependency-free syntax highlighter for fenced code blocks.
//
// It is a single-pass lexer, not a parser: it recognises comments, strings,
// numbers, keywords, built-in types, constants, function-call names and (for
// shell) $variables, and wraps each in <span class="tok-...">. That is
// enough to make code readable without pulling in a highlighting library or
// a CDN (this stack must work fully offline).
//
// Safety: input is gomarkdown's already-escaped block body. It is unescaped,
// tokenised, and every emitted piece is re-escaped, so no model-controlled
// text can ever become markup. The visible text is unchanged (the copy
// button still copies the original code).
// maxHighlightBytes skips highlighting for huge blocks; they are rare and
// the lexer is per-byte.
const maxHighlightBytes = 128 * 1024
type langSpec struct {
keywords, types, consts map[string]bool
lineComments []string
blockStart, blockEnd string
quotes string
tripleQuotes bool
foldCase bool // keywords are case-insensitive (SQL)
shellVars bool
jsonKeys bool
}
func wordSet(words string) map[string]bool {
m := make(map[string]bool)
for _, w := range strings.Fields(words) {
m[w] = true
}
return m
}
var (
goSpec = &langSpec{
keywords: wordSet(`break case chan const continue default defer else fallthrough for func go goto if
import interface map package range return select struct switch type var`),
types: wordSet(`string int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 uintptr byte rune
float32 float64 complex64 complex128 bool error any`),
consts: wordSet(`true false nil iota`),
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
quotes: "\"'`",
}
jsSpec = &langSpec{
keywords: wordSet(`async await break case catch class const continue debugger default delete do else
export extends finally for from function if import in instanceof let new of return static super
switch this throw try typeof var void while with yield interface type enum implements namespace
declare readonly public private protected abstract as`),
types: wordSet(`string number boolean any unknown never object Promise Array Map Set`),
consts: wordSet(`true false null undefined NaN Infinity`),
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
quotes: "\"'`",
}
pySpec = &langSpec{
keywords: wordSet(`and as assert async await break class continue def del elif else except finally for
from global if import in is lambda nonlocal not or pass raise return try while with yield match case`),
types: wordSet(`int str float bool list dict set tuple bytes object`),
consts: wordSet(`True False None`),
lineComments: []string{"#"},
quotes: "\"'", tripleQuotes: true,
}
shSpec = &langSpec{
keywords: wordSet(`if then else elif fi for while until do done case esac in function select time
return exit break continue export local readonly declare unset source alias`),
types: wordSet(`echo cd ls cat grep sed awk find git go make docker curl mkdir rm cp mv chmod chown
touch sudo npm pip python gofmt`),
lineComments: []string{"#"},
quotes: "\"'", shellVars: true,
}
jsonSpec = &langSpec{
consts: wordSet(`true false null`),
quotes: `"`, jsonKeys: true,
}
sqlSpec = &langSpec{
keywords: wordSet(`select from where insert into values update set delete create table alter drop index
join left right inner outer full cross on group by order having limit offset as and or not in is
like between exists union all distinct case when then else end primary key foreign references
default unique constraint with returning begin commit rollback extension if`),
types: wordSet(`int integer bigint smallint serial bigserial text varchar char boolean bool date
timestamp timestamptz numeric decimal float double real jsonb json uuid vector`),
consts: wordSet(`true false null`),
lineComments: []string{"--"}, blockStart: "/*", blockEnd: "*/",
quotes: "\"'", foldCase: true,
}
cSpec = &langSpec{
keywords: wordSet(`auto break case const continue default do else enum extern for goto if inline
register return sizeof static struct switch typedef union volatile while class namespace
template typename public private protected virtual new delete using try catch throw`),
types: wordSet(`void char short int long float double signed unsigned bool size_t int8_t int16_t
int32_t int64_t uint8_t uint16_t uint32_t uint64_t string`),
consts: wordSet(`true false NULL nullptr`),
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
quotes: "\"'",
}
javaSpec = &langSpec{
keywords: wordSet(`abstract assert break case catch class const continue default do else enum extends
final finally for if implements import instanceof interface native new package private protected
public return static super switch synchronized this throw throws try void volatile while`),
types: wordSet(`boolean byte char short int long float double String Object List Map Set`),
consts: wordSet(`true false null`), lineComments: []string{"//"},
blockStart: "/*", blockEnd: "*/", quotes: "\"'",
}
// No ' in quotes: Rust lifetimes ('a) would swallow the rest of the line.
rustSpec = &langSpec{
keywords: wordSet(`as async await break const continue crate dyn else enum extern fn for if impl in
let loop match mod move mut pub ref return self Self static struct super trait type unsafe use
where while`),
types: wordSet(`i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 bool char str String
Vec Option Result Box`),
consts: wordSet(`true false None Some Ok Err`), lineComments: []string{"//"},
blockStart: "/*", blockEnd: "*/", quotes: `"`,
}
langByName = map[string]*langSpec{
"go": goSpec, "golang": goSpec,
"js": jsSpec, "javascript": jsSpec, "jsx": jsSpec, "ts": jsSpec, "typescript": jsSpec, "tsx": jsSpec,
"py": pySpec, "python": pySpec,
"sh": shSpec, "bash": shSpec, "shell": shSpec, "zsh": shSpec, "console": shSpec,
"json": jsonSpec,
"sql": sqlSpec, "postgres": sqlSpec, "psql": sqlSpec,
"c": cSpec, "cpp": cSpec, "c++": cSpec, "cc": cSpec, "h": cSpec, "hpp": cSpec,
"java": javaSpec,
"rust": rustSpec, "rs": rustSpec,
}
)
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
func isIdentStart(c byte) bool { return c == '_' || c == '$' || (c|0x20 >= 'a' && c|0x20 <= 'z') }
func isIdent(c byte) bool { return isIdentStart(c) || isDigit(c) }
func spanned(b *strings.Builder, class, text string) {
if class == "" {
b.WriteString(stdhtml.EscapeString(text))
return
}
b.WriteString(`<span class="tok-`)
b.WriteString(class)
b.WriteString(`">`)
b.WriteString(stdhtml.EscapeString(text))
b.WriteString(`</span>`)
}
// highlight takes a fenced block's language and its HTML-escaped body and
// returns the body with token spans. Unknown languages come back unchanged.
func highlight(lang, escapedBody string) string {
lang = strings.ToLower(strings.TrimSpace(lang))
if len(escapedBody) > maxHighlightBytes {
return escapedBody
}
if lang == "diff" || lang == "patch" {
return highlightDiff(stdhtml.UnescapeString(escapedBody))
}
spec := langByName[lang]
if spec == nil {
return escapedBody
}
return spec.lex(stdhtml.UnescapeString(escapedBody))
}
func highlightDiff(src string) string {
var b strings.Builder
lines := strings.Split(src, "\n")
for i, ln := range lines {
class := ""
switch {
case strings.HasPrefix(ln, "+++") || strings.HasPrefix(ln, "---"):
class = "hunk"
case strings.HasPrefix(ln, "@@"):
class = "hunk"
case strings.HasPrefix(ln, "+"):
class = "add"
case strings.HasPrefix(ln, "-"):
class = "del"
}
spanned(&b, class, ln)
if i < len(lines)-1 {
b.WriteByte('\n')
}
}
return b.String()
}
func (s *langSpec) lex(src string) string {
var b strings.Builder
b.Grow(len(src) + len(src)/2)
i := 0
for i < len(src) {
c := src[i]
rest := src[i:]
if n := s.commentLen(src, i); n > 0 {
spanned(&b, "cmt", rest[:n])
i += n
continue
}
if strings.IndexByte(s.quotes, c) >= 0 {
n := s.stringLen(rest)
class := "str"
if s.jsonKeys {
k := i + n
for k < len(src) && (src[k] == ' ' || src[k] == '\t') {
k++
}
if k < len(src) && src[k] == ':' {
class = "key"
}
}
spanned(&b, class, rest[:n])
i += n
continue
}
if isDigit(c) && (i == 0 || !isIdent(src[i-1])) {
j := i + 1
for j < len(src) && (isIdent(src[j]) || (src[j] == '.' && j+1 < len(src) && isDigit(src[j+1]))) {
j++
}
spanned(&b, "num", src[i:j])
i = j
continue
}
if s.shellVars && c == '$' && i+1 < len(src) && (isIdentStart(src[i+1]) || src[i+1] == '{') {
j := i + 1
if src[j] == '{' {
if k := strings.IndexAny(src[j:], "}\n"); k > 0 && src[j+k] == '}' {
j += k + 1
} else {
j++
}
} else {
for j < len(src) && isIdent(src[j]) {
j++
}
}
spanned(&b, "var", src[i:j])
i = j
continue
}
if isIdentStart(c) {
j := i + 1
for j < len(src) && isIdent(src[j]) {
j++
}
spanned(&b, s.classify(src[i:j], src[j:]), src[i:j])
i = j
continue
}
_, size := utf8.DecodeRuneInString(rest)
spanned(&b, "", rest[:size])
i += size
}
return b.String()
}
func (s *langSpec) classify(word, after string) string {
w := word
if s.foldCase {
w = strings.ToLower(word)
}
switch {
case s.keywords[w]:
return "kw"
case s.types[w]:
return "type"
case s.consts[w]:
return "const"
}
if len(s.types) > 0 || len(s.keywords) > 0 { // JSON has no functions
for k := 0; k < len(after); k++ {
if after[k] == ' ' || after[k] == '\t' {
continue
}
if after[k] == '(' {
return "fn"
}
break
}
}
return ""
}
// commentLen returns the length of a comment starting at src[i], or 0.
func (s *langSpec) commentLen(src string, i int) int {
rest := src[i:]
for _, lc := range s.lineComments {
if !strings.HasPrefix(rest, lc) {
continue
}
// A "#" only starts a comment at a word boundary, so "a#b" and
// "$#" aren't swallowed.
if lc == "#" && i > 0 && src[i-1] != ' ' && src[i-1] != '\t' && src[i-1] != '\n' && src[i-1] != ';' {
continue
}
if k := strings.IndexByte(rest, '\n'); k >= 0 {
return k
}
return len(rest)
}
if s.blockStart != "" && strings.HasPrefix(rest, s.blockStart) {
if k := strings.Index(rest[len(s.blockStart):], s.blockEnd); k >= 0 {
return len(s.blockStart) + k + len(s.blockEnd)
}
return len(rest)
}
return 0
}
// stringLen returns the length of the string literal starting at rest[0].
// Unterminated ' and " literals end at the newline, so one stray quote
// can't colour the rest of the block.
func (s *langSpec) stringLen(rest string) int {
q := rest[0]
if s.tripleQuotes {
if t := strings.Repeat(string(q), 3); strings.HasPrefix(rest, t) {
if k := strings.Index(rest[3:], t); k >= 0 {
return 3 + k + 3
}
return len(rest)
}
}
if q == '`' { // raw / multi-line
if k := strings.IndexByte(rest[1:], '`'); k >= 0 {
return 1 + k + 1
}
return len(rest)
}
j := 1
for j < len(rest) {
switch rest[j] {
case '\\':
j += 2
continue
case q:
return j + 1
case '\n':
return j
}
j++
}
if j > len(rest) {
j = len(rest)
}
return j
}

View File

@@ -0,0 +1,81 @@
package renderkit
import (
stdhtml "html"
"regexp"
"strings"
"testing"
)
var tagRe = regexp.MustCompile(`<[^>]*>`)
// visibleText strips the span markup and unescapes, i.e. what the user
// sees and what the copy button copies.
func visibleText(s string) string { return stdhtml.UnescapeString(tagRe.ReplaceAllString(s, "")) }
func TestHighlightPreservesText(t *testing.T) {
samples := map[string]string{
"go": "package main\n\n// hi\nfunc main() {\n\tx := \"a<b>\" + `raw\nline`\n\tif x != nil { return 42 }\n}\n",
"py": "def f(x):\n \"\"\"doc\"\"\"\n return x # c\n",
"bash": "echo \"$HOME\" ${A} | grep 'x' # note\nfoo#bar $#\n",
"json": "{\"a\": [1, 2.5, true, null], \"b\": \"x\"}",
"sql": "SELECT count(*) FROM t WHERE a = 'x' -- c\n",
"rust": "fn f<'a>(x: &'a str) -> u32 { 1 }\n",
"diff": "@@ -1 +1 @@\n-old\n+new\n ctx\n",
}
for lang, src := range samples {
out := highlight(lang, stdhtml.EscapeString(src))
if got := visibleText(out); got != src {
t.Errorf("%s: text changed\nwant %q\ngot %q", lang, src, got)
}
if !strings.Contains(out, "<span class=\"tok-") {
t.Errorf("%s: nothing was highlighted: %s", lang, out)
}
}
}
func TestHighlightTokens(t *testing.T) {
out := highlight("go", stdhtml.EscapeString("func main() { return \"a<b>\" } // done"))
for _, want := range []string{
`<span class="tok-kw">func</span>`,
`<span class="tok-fn">main</span>`,
`<span class="tok-kw">return</span>`,
`<span class="tok-str">&#34;a&lt;b&gt;&#34;</span>`,
`<span class="tok-cmt">// done</span>`,
} {
if !strings.Contains(out, want) {
t.Errorf("missing %s in %s", want, out)
}
}
if !strings.Contains(highlight("json", `&#34;k&#34;: 1`), `tok-key`) {
t.Error("json key not detected")
}
if !strings.Contains(highlight("sql", "select 1"), `<span class="tok-kw">select</span>`) {
t.Error("sql keywords should be case-insensitive")
}
}
func TestHighlightNeverInjectsMarkup(t *testing.T) {
evil := "x := \"</span><script>alert(1)</script>\" // <img src=x onerror=1>"
out := highlight("go", stdhtml.EscapeString(evil))
if strings.Contains(out, "<script") || strings.Contains(out, "<img") {
t.Fatalf("markup leaked: %s", out)
}
}
func TestHighlightUnknownLanguageUnchanged(t *testing.T) {
in := "a &lt; b"
if got := highlight("brainfuck", in); got != in {
t.Fatalf("got %q", got)
}
if got := highlight("", in); got != in {
t.Fatalf("got %q", got)
}
}
func TestMarkdownHighlightsFencedCode(t *testing.T) {
out := Markdown("```go\nfunc main() {}\n```")
if !strings.Contains(out, `<span class="tok-kw">func</span>`) || !strings.Contains(out, "copy-btn") {
t.Fatalf("not highlighted: %s", out)
}
}

94
pkg/renderkit/markdown.go Normal file
View File

@@ -0,0 +1,94 @@
// Package render turns raw assistant text into safe HTML: Markdown for
// finished messages, and simple HTML-escaping (with <br> line breaks)
// for the plain-text tokens shown while a reply is still streaming.
package renderkit
import (
stdhtml "html"
"regexp"
"strings"
"github.com/gomarkdown/markdown"
mdhtml "github.com/gomarkdown/markdown/html"
mdparser "github.com/gomarkdown/markdown/parser"
)
// gomarkdown's Parser and Renderer are single-use and stateful ("Parser is
// not reusable" — a second Parse on the same instance panics, and sharing
// one across goroutines would race), so both are built fresh per call.
const (
mdParserExtensions = mdparser.CommonExtensions | mdparser.AutoHeadingIDs | mdparser.FencedCode
mdRendererFlags = mdhtml.CommonFlags | mdhtml.HrefTargetBlank | mdhtml.SkipHTML | mdhtml.Safelink
)
// fencedCodeBlock matches gomarkdown's output for a fenced code block:
// <pre><code class="language-xxx">...escaped content...</code></pre>
// (the language class is only present when the fence specified one,
// e.g. ```go). (?s) lets "." match newlines, since code blocks are
// almost always multi-line.
var fencedCodeBlock = regexp.MustCompile(`(?s)<pre><code(?: class="language-([\w+.-]*)")?>(.*?)</code></pre>`)
// Markdown renders a complete Markdown document (the agent's finished
// reply) to HTML for embedding in a chat bubble.
//
// The text being rendered is model output, and the model reads files
// and command output that may contain attacker-controlled text (prompt
// injection), so it is treated as untrusted: raw HTML in the Markdown
// source is dropped (SkipHTML) and only safe link schemes are linked
// (Safelink — no javascript: URLs). Code spans and fenced blocks are
// still HTML-escaped by gomarkdown, so showing "<div>" in code works.
//
// Fenced code blocks get an extra post-processing pass (addCodeBlockChrome)
// to wrap them in a header bar with a language label and copy button,
// matching the rest of the UI's terminal-chat styling — done as plain
// string manipulation on gomarkdown's already-escaped output rather
// than pulling in a syntax-highlighting dependency (see the module's
// deliberately small go.mod). Keywords, strings, comments, numbers and so
// on are coloured by the small built-in lexer in highlight.go.
func Markdown(src string) string {
return addCodeBlockChrome(rawMarkdown(src))
}
// rawMarkdown is gomarkdown's output before addCodeBlockChrome. Split out so
// Snippets (snippets.go) can enumerate fenced blocks in exactly the same
// order Markdown renders them.
func rawMarkdown(src string) string {
p := mdparser.NewWithExtensions(mdParserExtensions)
r := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdRendererFlags})
return string(markdown.ToHTML([]byte(src), p, r))
}
// addCodeBlockChrome wraps every fenced code block in a `.code-block`
// container with a header (language label + copy button) so it reads
// like a small file viewer instead of a bare <pre>. The copy button has
// no inline JS handler — web/static/js/app.js listens for clicks on
// `.copy-btn` via event delegation and reads the code from a sibling
// <pre>, so this still degrades gracefully (the button just does
// nothing) if that script is blocked, matching the rest of the app's
// zero-required-JS posture.
func addCodeBlockChrome(html string) string {
return fencedCodeBlock.ReplaceAllStringFunc(html, func(block string) string {
m := fencedCodeBlock.FindStringSubmatch(block)
lang := m[1]
body := m[2]
label := lang
if label == "" {
label = "text"
}
return `<div class="code-block">` +
`<div class="code-block-header">` +
`<span class="code-block-lang">` + stdhtml.EscapeString(label) + `</span>` +
`<button type="button" class="copy-btn" aria-label="Copy code">Copy</button>` +
`</div>` +
`<pre><code class="language-` + stdhtml.EscapeString(label) + `">` + highlight(lang, body) + `</code></pre>` +
`</div>`
})
}
// PlainStream escapes raw text and converts newlines to <br>, for
// rendering the in-progress streamed response before the final Markdown
// pass runs on the "done" event.
func PlainStream(src string) string {
escaped := stdhtml.EscapeString(src)
return strings.ReplaceAll(escaped, "\n", "<br>")
}

View File

@@ -0,0 +1,49 @@
package renderkit
import (
"strings"
"testing"
)
func TestMarkdownDropsRawHTMLAndUnsafeLinks(t *testing.T) {
out := Markdown("hi <script>alert(1)</script> <img src=x onerror=alert(1)> [x](javascript:alert(1))\n\n<div onclick=\"x()\">boom</div>")
for _, bad := range []string{"<script", "<img", "onerror", "onclick", `href="javascript:`} {
if strings.Contains(out, bad) {
t.Fatalf("output contains %q:\n%s", bad, out)
}
}
}
func TestMarkdownKeepsCodeAndFormatting(t *testing.T) {
out := Markdown("**bold**\n\n```go\nx := \"<div>\" + `a`\n```\n\n`<b>`")
for _, want := range []string{"<strong>bold</strong>", "code-block", "copy-btn", "&lt;div&gt;", "&lt;b&gt;"} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in:\n%s", want, out)
}
}
}
func TestPlainStreamEscapes(t *testing.T) {
if got := PlainStream("<b>a</b>\nb"); got != "&lt;b&gt;a&lt;/b&gt;<br>b" {
t.Fatal(got)
}
}
// Regression: gomarkdown's Parser panics if reused, which used to make every
// reply after the first fail. Also exercised concurrently for the race detector.
func TestMarkdownIsReusableAndConcurrencySafe(t *testing.T) {
done := make(chan struct{})
for i := 0; i < 8; i++ {
go func() {
defer func() { done <- struct{}{} }()
for j := 0; j < 20; j++ {
if out := Markdown("# title\n\ntext"); !strings.Contains(out, "<h1") {
t.Error(out)
}
}
}()
}
for i := 0; i < 8; i++ {
<-done
}
}

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