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

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
}
}

76
pkg/renderkit/snippets.go Normal file
View File

@@ -0,0 +1,76 @@
package renderkit
import (
stdhtml "html"
"path"
"regexp"
"strings"
)
// Snippet describes one fenced code block in an assistant reply that is
// worth offering as a download.
type Snippet struct {
Index int // position among ALL fenced blocks in the reply (matches the DOM order of .code-block)
Lang string // lower-cased fence language
Ext string // file extension including the dot, e.g. ".go"
Label string // display name of the type, e.g. "Go"
Lines int
Bytes int
// Filename is set when the block's first line is a comment naming a
// file with the matching extension (e.g. "// internal/db/users.go"),
// which models often write; only the base name is kept.
Filename string
}
var firstLineFilename = regexp.MustCompile(`^\s*(?://|#|--|/\*|<!--)\s*([\w./-]+\.\w+)\s*(?:\*/|-->)?\s*$`)
func filenameHint(body, ext string) string {
first, _, _ := strings.Cut(body, "\n")
m := firstLineFilename.FindStringSubmatch(first)
if m == nil {
return ""
}
base := path.Base(m[1])
if path.Ext(base) != ext || base == ext {
return ""
}
return base
}
type snippetType struct{ ext, label string }
var snippetTypes = map[string]snippetType{
"go": {".go", "Go"}, "golang": {".go", "Go"},
"js": {".js", "JavaScript"}, "javascript": {".js", "JavaScript"},
"jsx": {".jsx", "JSX"}, "ts": {".ts", "TypeScript"}, "typescript": {".ts", "TypeScript"}, "tsx": {".tsx", "TSX"},
"py": {".py", "Python"}, "python": {".py", "Python"},
"sh": {".sh", "Shell"}, "bash": {".sh", "Shell"}, "shell": {".sh", "Shell"}, "zsh": {".sh", "Shell"},
"json": {".json", "JSON"}, "sql": {".sql", "SQL"},
"c": {".c", "C"}, "h": {".h", "C header"}, "cpp": {".cpp", "C++"}, "c++": {".cpp", "C++"},
"java": {".java", "Java"}, "rust": {".rs", "Rust"}, "rs": {".rs", "Rust"},
"diff": {".patch", "Patch"}, "patch": {".patch", "Patch"},
"yaml": {".yml", "YAML"}, "yml": {".yml", "YAML"}, "toml": {".toml", "TOML"},
"html": {".html", "HTML"}, "css": {".css", "CSS"},
"md": {".md", "Markdown"}, "markdown": {".md", "Markdown"},
}
// Snippets lists the fenced code blocks in src that have a recognised
// language, in the order Markdown renders them. Blocks with no language (or
// an unknown one) are skipped, but still counted in Index.
func Snippets(src string) []Snippet {
var out []Snippet
for i, m := range fencedCodeBlock.FindAllStringSubmatch(rawMarkdown(src), -1) {
lang := strings.ToLower(m[1])
t, ok := snippetTypes[lang]
if !ok {
continue
}
body := stdhtml.UnescapeString(m[2])
out = append(out, Snippet{
Index: i, Lang: lang, Ext: t.ext, Label: t.label,
Lines: strings.Count(strings.TrimRight(body, "\n"), "\n") + 1,
Bytes: len(body), Filename: filenameHint(body, t.ext),
})
}
return out
}

View File

@@ -0,0 +1,34 @@
package renderkit
import "testing"
func TestSnippets(t *testing.T) {
src := "intro\n\n```\nno lang\n```\n\n```go\npackage main\n\nfunc main() {}\n```\n\n```python\nprint(1)\n```\n\n```weird\nx\n```\n"
got := Snippets(src)
if len(got) != 2 {
t.Fatalf("want 2 snippets, got %+v", got)
}
if got[0].Index != 1 || got[0].Ext != ".go" || got[0].Lines != 3 {
t.Errorf("go snippet wrong: %+v", got[0])
}
if got[1].Index != 2 || got[1].Ext != ".py" || got[1].Label != "Python" {
t.Errorf("python snippet wrong: %+v", got[1])
}
}
func TestSnippetFilenameHint(t *testing.T) {
src := "```go\n// internal/db/users.go\npackage db\n\nvar X = 1\n```\n\n```go\n// just a comment\npackage x\n\nvar Y = 2\n```\n\n```python\n# notes.txt\nprint(1)\nprint(2)\n```\n"
got := Snippets(src)
if len(got) != 3 {
t.Fatalf("want 3, got %+v", got)
}
if got[0].Filename != "users.go" {
t.Errorf("want users.go, got %q", got[0].Filename)
}
if got[1].Filename != "" {
t.Errorf("plain comment must not become a filename: %q", got[1].Filename)
}
if got[2].Filename != "" {
t.Errorf("extension mismatch (.txt vs .py) must be ignored: %q", got[2].Filename)
}
}

208
pkg/skillkit/rag.go Normal file
View File

@@ -0,0 +1,208 @@
// rag.go adds retrieval-augmented search on top of the plain
// read-a-whole-file skill lookup in skillkit.go. The read_office_skill
// tool (skillkit.Read) is the right fit when the model already knows
// which format it needs and wants that skill's full guidance before a
// multi-step task — but for a narrow, specific question ("can a cell
// hold a formula", "how do I bold part of a paragraph"), returning an
// entire 3-4KB skill file just to answer it is the same imprecision
// Claude Code's own skills avoid by splitting a large skill into a
// short SKILL.md plus separate reference files loaded on demand.
// Because every skill here already follows one convention — a "##"
// heading per topic — each section is naturally a single, coherent
// chunk, and because the whole corpus is a few dozen short sections
// fixed at compile time (go:embed, never changes between deploys),
// embedding it once per process and ranking in memory is simpler and
// faster than standing up a vector-DB round trip for content this
// small and this static. search_codebase, by contrast, indexes a
// workspace's actual source files — large, per-user, and changing —
// which is exactly the shape services/knowledge's Postgres/pgvector
// pipeline exists for; that pipeline would be the wrong tool here, not
// a missing piece.
package skillkit
import (
"context"
"fmt"
"math"
"sort"
"strings"
"sync"
)
// Embedder is the subset of *vllmkit.Client this package needs —
// declared as a narrow interface (rather than importing vllmkit
// directly) so the caller supplies whatever embedding client it
// already has, and so this package stays testable without a live
// vLLM instance.
type Embedder interface {
Embed(ctx context.Context, text string) ([]float32, error)
}
// Chunk is one semantically coherent section of a skill file — in
// practice, one "## Heading" and the text under it (or the file's
// title/intro, for the section before the first heading).
type Chunk struct {
Skill string // the skill this section belongs to, e.g. "docx"
Heading string // the section's heading, e.g. "Editing an existing document" — "Overview" for the lead-in before any "##"
Text string // the section's full text, heading line included — what gets embedded, and what Search returns
}
// chunkSkill splits one skill's raw markdown body into Chunks, cutting
// at every "## " line. This mirrors the actual structure every file in
// skills/*.md already uses, so no separate chunk-size tuning
// (domain.ChunkText's char budget, needed for arbitrary source code) is
// necessary here — a skill's own section boundaries are already the
// right retrieval granularity.
func chunkSkill(name, body string) []Chunk {
var (
chunks []Chunk
heading string
cur []string
)
flush := func() {
text := strings.TrimSpace(strings.Join(cur, "\n"))
if text == "" {
return
}
h := heading
if h == "" {
h = "Overview"
}
chunks = append(chunks, Chunk{Skill: name, Heading: h, Text: text})
}
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(line, "## ") {
flush()
heading = strings.TrimPrefix(line, "## ")
cur = []string{line}
continue
}
cur = append(cur, line)
}
flush()
return chunks
}
// allChunks returns every skill's chunks, in skillkit.Names() order.
func allChunks() []Chunk {
var out []Chunk
for _, name := range Names() {
out = append(out, chunkSkill(name, registry[name].Body)...)
}
return out
}
// Match is one ranked search result.
type Match struct {
Chunk
Score float64 // cosine similarity to the query, higher is more relevant
}
type vectorChunk struct {
Chunk
vec []float32
}
// Index is a lazily-built, in-memory semantic index over every skill's
// chunks. The zero value is not usable — construct with NewIndex. An
// Index is safe for concurrent use and is meant to be built once and
// reused for the life of the process (see package doc for why an
// in-memory index, not a persisted one, is the right call here).
type Index struct {
embedder Embedder
mu sync.Mutex
vectors []vectorChunk // nil until a build has succeeded at least once
}
// NewIndex returns an Index that will embed every skill chunk, once,
// the first time Search is called (not eagerly here) — so a
// deployment that never calls search_office_skills never pays the
// embedding cost at all, and the first real call pays it exactly once.
func NewIndex(embedder Embedder) *Index {
return &Index{embedder: embedder}
}
// ensureBuilt embeds every chunk if that hasn't already succeeded.
// Deliberately NOT a sync.Once: if vLLM is unreachable on the very
// first call, that failure must not be cached forever — the next
// Search should try again rather than fail for the rest of the
// process's life over one transient outage. Concurrent callers during
// a build simply serialize on the mutex rather than building twice.
func (ix *Index) ensureBuilt(ctx context.Context) error {
ix.mu.Lock()
defer ix.mu.Unlock()
if ix.vectors != nil {
return nil
}
chunks := allChunks()
vectors := make([]vectorChunk, 0, len(chunks))
for _, c := range chunks {
vec, err := ix.embedder.Embed(ctx, c.Text)
if err != nil {
return fmt.Errorf("skillkit: embed %s/%s: %w", c.Skill, c.Heading, err)
}
vectors = append(vectors, vectorChunk{Chunk: c, vec: vec})
}
ix.vectors = vectors
return nil
}
// Search embeds query and returns the topK chunks whose own embedding
// is most cosine-similar to it, best first. skillFilter, if non-empty,
// restricts the search to one skill's chunks (matched the same way
// Read does: case-insensitive against the skill name). topK<=0 defaults
// to 4.
func (ix *Index) Search(ctx context.Context, query string, topK int, skillFilter string) ([]Match, error) {
if topK <= 0 {
topK = 4
}
if err := ix.ensureBuilt(ctx); err != nil {
return nil, err
}
qvec, err := ix.embedder.Embed(ctx, query)
if err != nil {
return nil, fmt.Errorf("skillkit: embed query: %w", err)
}
ix.mu.Lock()
vectors := ix.vectors
ix.mu.Unlock()
matches := make([]Match, 0, len(vectors))
for _, v := range vectors {
if skillFilter != "" && !strings.EqualFold(v.Skill, skillFilter) {
continue
}
matches = append(matches, Match{Chunk: v.Chunk, Score: cosineSimilarity(qvec, v.vec)})
}
sort.Slice(matches, func(i, j int) bool { return matches[i].Score > matches[j].Score })
if len(matches) > topK {
matches = matches[:topK]
}
return matches, nil
}
// cosineSimilarity is the standard cosine of the angle between two
// vectors, in [-1, 1] for any nonzero inputs (in practice embeddings
// from one model land in a narrower positive range). Defensively walks
// only the shorter of the two lengths and returns 0 for a zero vector,
// rather than panicking or dividing by zero, though in normal operation
// every vector here comes from the same embedding model and is the
// same length.
func cosineSimilarity(a, b []float32) float64 {
n := len(a)
if len(b) < n {
n = len(b)
}
var dot, normA, normB float64
for i := 0; i < n; i++ {
dot += float64(a[i]) * float64(b[i])
normA += float64(a[i]) * float64(a[i])
normB += float64(b[i]) * float64(b[i])
}
if normA == 0 || normB == 0 {
return 0
}
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
}

113
pkg/skillkit/skillkit.go Normal file
View File

@@ -0,0 +1,113 @@
// Package skillkit implements, for enkly's local agent, the same
// progressive-disclosure "skill" pattern Claude Code uses for its
// own document-editing skills: short, versioned markdown files that
// document how to use a tool family well — conventions, best
// practices, and honestly-stated limitations — kept out of the tool
// schema itself (which the model sees on every single request) and
// instead loaded on demand, once, the first time the model is about
// to actually use that tool family.
//
// Today that's the three office document skills (docx, xlsx, pptx),
// exposed to the model through the read_office_skill tool (see
// toolexec/office_skill.go and toolcatalog/catalog.go). The skill
// files themselves live in skills/*.md as plain, human-editable
// markdown — updating a skill's guidance is a markdown edit, not a Go
// code change, the same way updating a Claude Code skill is.
package skillkit
import (
"embed"
"fmt"
"sort"
"strings"
)
//go:embed skills/*.md
var skillFS embed.FS
// Skill is one loaded SKILL.md-equivalent file.
type Skill struct {
// Name is the catalog key the model passes to read_office_skill —
// the file's basename without ".md" (e.g. "docx").
Name string
// Description is the one-line summary (the "> ..." blockquote
// line right after the file's title) shown in the
// read_office_skill tool description, so the model can pick the
// right skill without having read any of them yet — mirroring how
// Claude Code lists available skills by their one-line
// descriptions before reading a full SKILL.md.
Description string
// Body is the file's full content, returned verbatim to the model
// when it reads this skill.
Body string
}
var registry = mustLoad()
func mustLoad() map[string]Skill {
entries, err := skillFS.ReadDir("skills")
if err != nil {
// Embedded at build time; a failure here means the package
// itself is broken, not a runtime condition callers can
// recover from.
panic(fmt.Sprintf("skillkit: embedded skills directory unreadable: %v", err))
}
reg := make(map[string]Skill, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
raw, err := skillFS.ReadFile("skills/" + entry.Name())
if err != nil {
panic(fmt.Sprintf("skillkit: reading embedded skills/%s: %v", entry.Name(), err))
}
name := strings.TrimSuffix(entry.Name(), ".md")
reg[name] = Skill{Name: name, Description: parseDescription(string(raw)), Body: string(raw)}
}
return reg
}
// parseDescription pulls the one-line "> ..." blockquote that follows
// each skill file's title — see skills/*.md for the convention.
// Falls back to the empty string if a file doesn't follow it, rather
// than failing the whole package load over one malformed doc comment.
func parseDescription(body string) string {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "> ") {
return strings.TrimPrefix(line, "> ")
}
}
return ""
}
// List returns every available skill (name + description, no body),
// sorted by name, for building the read_office_skill tool's
// description and for any other "what skills exist" listing.
func List() []Skill {
out := make([]Skill, 0, len(registry))
for _, s := range registry {
out = append(out, Skill{Name: s.Name, Description: s.Description})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Read returns one skill's full content by name, or ok=false if no
// such skill exists.
func Read(name string) (Skill, bool) {
s, ok := registry[strings.ToLower(strings.TrimSpace(name))]
return s, ok
}
// Names returns every valid skill name, sorted — used to build the
// read_office_skill tool's JSON-schema "enum" so the model can only
// ever request a skill that actually exists.
func Names() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
sort.Strings(names)
return names
}

127
pkg/skillkit/skills/docx.md Normal file
View File

@@ -0,0 +1,127 @@
# docx — Word documents
> Best practices for create_document / read_document / edit_document: structure, styles, and what these tools can't do yet.
## Content model
A `.docx` here is just an ordered list of paragraphs. Each paragraph has:
- `text` — plain text (no inline bold/italic *within* one paragraph — see
Limitations; `bold`/`italic`/`underline` apply to the whole paragraph).
- `style` — one of `normal`, `heading1`, `heading2`, `heading3`, `bullet`.
Defaults to `normal`.
- `bold` — bolds the whole paragraph. Implied `true` for headings; set it
explicitly on a `normal`/`bullet` paragraph if you want it bold too.
- `italic` — italicizes the whole paragraph. Defaults to `false`.
- `underline` — underlines the whole paragraph. Defaults to `false`.
Combine freely with `bold`/`italic` and with any `style`.
There is no separate "title page" or "section" concept — structure a
document the way you'd structure Markdown: one `heading1` for the
document title, `heading2`/`heading3` for subsections, `bullet`
paragraphs for lists, `normal` for body copy.
## Writing a good structured document
- Lead with a `heading1`. Don't put the document's title in a `normal`
paragraph — it won't render distinctly and won't show up in Word's
navigation pane.
- Keep paragraphs as actual paragraphs, not single giant blocks — split
by idea the way you would in prose, not by sentence.
- Use `bullet` for genuinely list-shaped content (steps, options,
short items), not as a substitute for paragraph breaks.
- A short memo doesn't need every heading level — `heading1` +
`normal`/`bullet` is often enough. Reach for `heading2`/`heading3` only
when the document actually has that many levels of nesting.
## Editing an existing document
`edit_document` supports exactly two operations:
- `append_paragraph` — adds one new paragraph at the end. Same fields as
a `create_document` paragraph (including `bold`/`italic`/`underline`).
This preserves everything already in the file — tables, images,
headers, other formatting — untouched; only a new paragraph is added.
- `replace_text` — a literal, case-sensitive find/replace across every
text run in the document, including text inside table cells. This is
the structured analogue of `edit_file`'s `old_string`/`new_string`: it
matches plain text only, not formatting, and every occurrence is
replaced (there's no "exactly one match" requirement the way
`edit_file` has). One real caveat: Word sometimes splits what reads as
one phrase across more than one internal text run (spell-check and
revision tracking both do this), and `find` only matches within a
single run — if a replacement you expect to apply doesn't, try a
shorter, more literal substring, or `read_document` first to see the
text as the model sees it.
There's no "insert paragraph at position N" or "delete paragraph"
operation yet — if a document needs real restructuring (not just adding
to the end or swapping text), the practical option today is
`read_document` to get the current content, then `create_document` a
replacement with the full new paragraph list (after confirming with the
person you're overwriting it, the same judgment call you'd make before
`write_file`-replacing an existing text file).
Always `read_document` first if you're not certain what's already in the
file — don't guess at existing paragraph text before a `replace_text`
call.
## Redlining (Track Changes)
Two of `edit_document`'s operations record the edit as a reviewable
tracked change instead of rewriting the text in place:
- `append_paragraph_tracked` — same fields as `append_paragraph`, but
the new paragraph shows up in Word as a pending insertion (Review >
Track Changes), not as ordinary content.
- `replace_text_tracked` — same fields as `replace_text`, but instead
of silently rewriting matching text it marks the containing run as
changed: the old text struck through (a deletion) and the new text
inserted beside it. This works at whole-run granularity, not a
minimal character diff — in practice a run is usually already one
coherent span of matching formatting, so this reads as a natural
redline rather than an oversized strikethrough.
- Both accept an optional `author` (defaults to `"enkly agent"`) —
the reviewer name Word shows on hover and in the Reviewing pane.
Resolving changes: `accept_all_changes` makes every pending tracked
change in the document permanent (deletions removed, insertions kept
as ordinary text); `reject_all_changes` discards every pending
insertion and restores every pending deletion. Both act on *every*
tracked change in the file at once — there's no per-change accept or
reject yet, so say so if someone asks for that.
`read_document` automatically reports any pending tracked changes
(author + the affected text) after the plain text, so you don't need a
separate call to discover a document already has open redlines before
deciding whether to add more, accept, or reject them.
## Converting Markdown into a document
`create_document` accepts `markdown` as an alternative to `paragraphs`:
pass a Markdown document's source text and headings (`#`/`##`/`###`),
paragraphs, and bullet/numbered lists are converted into the matching
paragraph styles automatically (numbered lists come out as bullets —
there's no numbered-list style yet). Inline formatting (`**bold**`,
`*italic*`, links, inline code) is flattened to plain text, the same
whole-paragraph-only limit `paragraphs` already has. Tables and raw
HTML blocks in the source are dropped, not approximated — say so if a
Markdown document being converted has either.
## Limitations (so you don't promise more than the tool does)
- No inline run-level formatting — a paragraph is bold/italic/underline
or it isn't; there's no bolding one word mid-sentence.
- Can't create tables, images, headers/footers, footnotes, page breaks,
or columns. `read_document` and `replace_text` *do* see and reach
text already inside a table in a file you didn't create with this
tool (e.g. a user upload) — they just can't add a new table.
- No numbered lists — `bullet` is always a bullet, never `1.`/`2.`/`3.`.
- `read_document` extracts plain text only (bullets prefixed `"- "`,
one line per paragraph wherever it occurs, table cells included);
images and complex formatting in a real user-uploaded `.docx` are
still invisible to it — just the text survives.
If someone explicitly asks for one of these (a table, an image, inline
bold mid-paragraph), say plainly that today's document tool doesn't
support it rather than silently producing something close but different.

View File

@@ -0,0 +1,62 @@
# Office document skills — overview
> Read this first if you're not sure which office skill applies, or if the task spans more than one document type.
enkly can create and edit real Word (`.docx`), Excel (`.xlsx`) and PowerPoint (`.pptx`)
files through nine tools (`create_document`/`read_document`/`edit_document`,
`create_spreadsheet`/`read_spreadsheet`/`edit_spreadsheet`,
`create_presentation`/`read_presentation`/`edit_presentation`), plus a
tenth, read-only `read_pdf` for extracting text out of a PDF someone
uploaded. Every creation/editing tool takes plain structured fields —
text, styles, cell values, slide titles and bullets — and never raw
OOXML/XML. All the file-format work happens server-side in
`pkg/officekit`; you only ever describe *content*.
## Required first step
Before your **first** call to any `create_*`/`edit_*` office tool in a
conversation, call `read_office_skill` with the matching skill name
(`docx`, `xlsx`, or `pptx`). Each skill documents the exact shape that
tool family expects and the specific things it can't do yet — that detail
isn't repeated in the tool schema itself, the same way Claude Code's own
document skills work. You don't need to re-read a skill you've already
read earlier in the same conversation. `read_pdf` is simple enough that
its own tool description is usually enough; read the **pdf** skill only
if a result looks wrong or someone asks what it can/can't extract.
## Picking the right skill
- A letter, report, memo, or any text-first document people will read top
to bottom → **docx**. Need redlining/Track Changes, or converting an
existing Markdown document straight into one? Still **docx** — see its
"Redlining" and "Converting Markdown" sections.
- Tabular data, a budget, a grid of numbers, anything with rows and
columns, including a workbook with more than one tab → **xlsx**.
- Slides for a talk, a pitch, or a deck summarizing something in bullet
points → **pptx**.
- Extracting text from an uploaded PDF (a report, contract, paper) →
**pdf** / `read_pdf`.
- A request that genuinely needs more than one of these (e.g. "write a
report and also a slide deck summarizing it") still gets one skill read
per format you'll actually create — read docx and pptx, not one
combined skill.
## What these tools are *not* for
There is no `create_pdf`/`edit_pdf` — `read_pdf` is extraction only. If
someone specifically needs a new `.pdf` produced, say so plainly rather
than silently substituting a `.docx` or `.pptx` — don't imply the file
you created is a PDF, and don't invent a conversion step that doesn't
exist.
## General working pattern
1. Read the matching skill (once per conversation, per format).
2. If you're changing an existing file rather than creating a new one,
`read_*` it first — don't guess at what paragraphs/cells/slides are
already there.
3. `create_*` for a brand-new file (it fails loudly if the path already
exists, by design — this is the structured-content analogue of
`write_file` vs `edit_file`), `edit_*` for a targeted change to one.
4. Report back in plain language what you made or changed — the file
itself is the deliverable, not a transcript of its contents.

View File

@@ -0,0 +1,51 @@
# pdf — reading PDFs
> Best practices for read_pdf: what it can extract, what it can't, and how to tell the difference.
## What it does
`read_pdf` extracts a PDF's plain text content, best-effort, and
returns it as one block of text — no tool call needed to set anything
up first. It works well on PDFs that have a real embedded text layer,
which is the overwhelming majority of PDFs produced by real software:
Word or LibreOffice's "Export/Save as PDF", a browser's "Print to
PDF", Pandoc, Pages, most invoicing/reporting tools, and so on.
There's no `create_pdf` or `edit_pdf` — this skill (and `read_pdf`)
is read-only, for extracting data out of a PDF someone uploaded, not
for producing one. If someone wants a *new* PDF-shaped deliverable,
build it as a `.docx`/`.pptx`/`.xlsx` with `create_document` /
`create_presentation` / `create_spreadsheet` instead and say plainly
that this deployment can't export straight to PDF yet.
## What it can't do
- **No OCR.** A scanned PDF (a photographed or scanned document saved
as a PDF of page images, with no underlying text layer) has nothing
for `read_pdf` to extract — it returns an error saying so rather
than returning nothing silently or guessing at content. If that
happens, say plainly that the PDF appears to be scanned/image-only
and this tool can't OCR it.
- **No layout.** Tables, multi-column text, headers/footers, and
figure captions all come back as part of the same flat text stream,
roughly in reading order but with no structural markers — a
two-column PDF's columns may interleave line by line rather than
reading column-by-column. Don't present extracted text as if it
preserves a PDF's original formatting or table structure.
- **Unusual font encodings** (a PDF whose fonts remap character codes
in a nonstandard way, with no usable mapping back to plain text) can
come out as mangled or missing text for just that portion of the
document, even when the rest extracts cleanly.
## Using it well
- Read the whole PDF first before answering questions about it — the
tool has no "just page 3" mode yet, it always returns everything it
can extract in one call.
- If the extracted text looks suspiciously short relative to what the
person describes the PDF as containing, say so rather than treating
a short result as the complete document — it may be mostly scanned
pages with only a cover page or two of real text.
- Don't infer a PDF's page count, visual layout, or anything about
images/charts in it from `read_pdf`'s output; it only ever reports
what text it found, never a page-by-page breakdown.

View File

@@ -0,0 +1,59 @@
# pptx — PowerPoint decks
> Best practices for create_presentation / read_presentation / edit_presentation: slide structure and what these tools can't do yet.
## Content model
Every slide is exactly a title plus a flat list of bullet lines — two
fixed text boxes, nothing else. There's no layout to choose, no manual
positioning, no per-bullet nesting (sub-bullets). If a slide's content
doesn't reduce cleanly to "one title, some bullets," it's being forced
into a shape it won't fit — say so rather than cramming a table or a
diagram's content into bullet text and calling it equivalent.
## Writing a good deck
- One idea per slide. If a slide's bullet list is getting long (much
past 5-6 lines), that's usually a sign it should be two slides, not
one dense one.
- Titles should be short and specific ("Q3 Revenue by Region", not
"Update") — they're the only thing distinguishing slides in
`read_presentation`'s output and in Word's/PowerPoint's own slide
sorter.
- Bullets are terse fragments, not full paragraphs — if a "bullet" is
three sentences long, trim it or split it into more bullets.
- A deck needs a title slide too: make slide 1 a title-only slide (title
set, `bullets` omitted or empty) rather than starting straight into
content.
## Editing an existing deck
`edit_presentation` supports exactly two operations:
- `add_slide` — appends one new slide at the end. Same `title`/`bullets`
shape as `create_presentation`.
- `update_slide` — replaces one existing slide's title and bullets
entirely, by 1-based `index`. This **replaces the whole slide's
content**, not a single bullet — if you only want to change one line,
`read_presentation` first, take its current title/bullets, and pass
back the full set with just that one line changed.
There's no "insert slide at position N," "delete slide," or "reorder
slides" operation yet. `read_presentation` first if you're not sure how
many slides exist or what index to target — don't guess at slide
numbers on a deck you haven't read in this conversation.
## Limitations (so you don't promise more than the tool does)
- No images, charts, tables, or speaker notes.
- No custom layouts or manual text-box positioning — always the same
title-plus-bullets shape, two fixed text boxes.
- No slide deletion or reordering.
- `read_presentation` extracts title + bullet text only; a real
user-uploaded `.pptx` with images, custom layouts or multiple text
boxes per slide will lose all of that and return text only.
If someone explicitly asks for an image, a chart, or a custom layout,
say plainly that today's presentation tool doesn't support it rather
than silently producing a bullet-only slide and calling it the same
thing.

View File

@@ -0,0 +1,79 @@
# xlsx — Excel workbooks
> Best practices for create_spreadsheet / read_spreadsheet / edit_spreadsheet: sheets, cell values, and sparse edits.
## Content model
A workbook is one or more sheets, each a row-major grid of plain-text
cell values. Row 1 is the top row, column A is the leftmost column — the
grid you pass is literally what lands in the file, top-to-bottom,
left-to-right. Each value is classified the same way on both
`create_spreadsheet` and `edit_spreadsheet`:
- Starts with `=` (e.g. `"=SUM(A1:A4)"`, `"=B2*C2"`) → stored as a live
Excel **formula**, same as typing it into a cell in Excel. Excel
computes and displays the result when the file is opened; this tool
never evaluates formulas itself, so `read_spreadsheet` right after
writing one shows the formula text (prefixed `=`), not a computed
number — that's expected, not a bug.
- Otherwise parses as a plain number (`"92"`, `"3.5"`, `"-1"`) → stored
as a **numeric** cell.
- Anything else, including something that merely *looks* numeric-ish
like `"$92"` or `"92%"`, is stored as **text**.
If you want a literal computed total rather than a live formula (e.g.
because the person asked for a static report, not a workbook they'll
keep editing), compute it yourself — with `solve_ml_math` for anything
nontrivial — and write the plain number instead of a formula string.
## Single sheet vs. multiple sheets
`create_spreadsheet` has two forms:
- Single sheet: `rows` (+ optional `sheet_name`, defaults to `"Sheet1"`).
- Multiple sheets: `sheets`, an array of `{name, rows}` — one entry per
tab, in the order they should appear. Use this whenever the request
implies more than one tab ("a workbook with a summary sheet and a
detail sheet", "separate sheets per quarter") — don't flatten
multi-sheet data onto one sheet with a column that says which sheet it
"would" be.
Pick one form, not both, in a single `create_spreadsheet` call.
## Reading and editing a specific sheet
`read_spreadsheet` and `edit_spreadsheet` both take an optional `sheet`
field: a 1-based index (`"2"`) or a sheet name (case-insensitive).
Omitting it means the first sheet. When a workbook has more than one
sheet, `read_spreadsheet`'s result starts with a line naming which sheet
you got and listing every sheet's name — read that line before assuming
you're looking at the sheet you meant, especially on a user-uploaded
file whose sheet order you don't already know.
`edit_spreadsheet` writes a **sparse** set of cells by A1-style
reference (e.g. `"B12"`) to one sheet; every other cell on that sheet is
left exactly as it was, and a reference past the current grid extends
it rather than erroring. There's no "edit multiple sheets in one call" —
make one `edit_spreadsheet` call per sheet that needs a change.
Always `read_spreadsheet` first if you're not sure what's already in a
sheet or what the other sheets are called, the same way you'd
`read_file` before `edit_file`.
## Limitations (so you don't promise more than the tool does)
- No cell formatting (bold, colors, borders, column widths, number
formats like currency or percentages).
- No charts.
- No sheet deletion or reordering, and no renaming a sheet after
creation.
- `read_spreadsheet` returns display strings only — no distinction
between a cell's underlying value and how Excel would format it. For
a formula cell, it shows Excel's own cached result when one exists
(always true for a real Excel-authored or user-uploaded file), or the
formula text itself (prefixed `=`) when nothing has computed it yet
(always true immediately after this tool writes one).
If someone explicitly asks for formatting or a chart, say plainly that
today's tool doesn't support it rather than quietly writing a plain,
unformatted workbook and calling it done.

196
pkg/turnopts/turnopts.go Normal file
View File

@@ -0,0 +1,196 @@
// Package turnopts parses a small set of leading slash-directives off a
// chat prompt — /effort, /fast, /system — into structured per-turn
// options. It exists as a pragmatic bridge: api/proto/agent/v1/
// agent.proto documents the "real" target API surface for effort
// control, fast mode and live system-entry updates (new StartTurnRequest
// fields), but wiring those through means regenerating gen/agent/v1
// with protoc — a build step this authoring environment has no network
// access to run (see ARCHITECTURE.md §6, "What's verified, what isn't",
// for the project's own established pattern of calling this out rather
// than hand-editing generated code and hoping). Parsing a few directive
// lines off the existing plain-string StartTurnRequest.prompt field
// needs no proto change at all, so it's what actually ships working
// today; application.Service.StartTurn calls Parse before anything else
// and the gateway's chat box documents the directives inline. Once
// `make proto` is run against the extended .proto, a thin follow-up can
// read the structured fields directly and this package becomes the
// fallback path for a plain-text client instead of the only path.
package turnopts
import (
"strings"
"enkly/pkg/effort"
)
// SystemEntryKind mirrors agentrun.SystemEntryKind's three values
// without importing the agentrun package (which would pull the agent
// service's whole internal/infrastructure tree into a leaf pkg/
// package that belongs to no particular service) — application.Service
// translates this to the real agentrun.SystemEntryKind at the one call
// site that actually constructs an agentrun.SystemEntry.
type SystemEntryKind string
const (
SystemEntryInstruction SystemEntryKind = "instruction"
SystemEntryPermission SystemEntryKind = "permission"
SystemEntryBudget SystemEntryKind = "budget"
)
// SystemEntry is one /system directive's parsed kind + content.
type SystemEntry struct {
Kind SystemEntryKind
Content string
}
// Options is everything Parse extracts from a prompt's leading
// directive lines.
type Options struct {
// Effort is the requested reasoning-depth level, or "" if no
// /effort directive was present — application.Service.StartTurn
// leaves the Agent's own default in force in that case rather than
// forcing a level.
Effort effort.Level
// Fast requests fast mode for this turn — see /fast's doc comment
// below and application.Service's fast-mode client selection.
Fast bool
// SystemEntries are every /system directive present, in the order
// they appeared.
SystemEntries []SystemEntry
// HadDirectives reports whether anything was actually parsed out of
// the prompt — lets a caller skip rebuilding a cleaned prompt
// string (Clean) when there's nothing to clean, which is the
// overwhelmingly common case.
HadDirectives bool
}
// directivePrefixes lists every recognized directive, longest-match
// order doesn't matter since each is matched as a whole leading token.
var directivePrefixes = []string{"/effort", "/fast", "/system"}
// isDirectiveLine reports whether line (already trimmed) opens with one
// of directivePrefixes followed by either nothing or whitespace — so
// "/effortless" (a real sentence someone might type) is correctly NOT
// treated as an /effort directive.
func isDirectiveLine(line string) (prefix string, rest string, ok bool) {
for _, p := range directivePrefixes {
if line == p {
return p, "", true
}
if strings.HasPrefix(line, p+" ") || strings.HasPrefix(line, p+"\t") {
return p, strings.TrimSpace(line[len(p):]), true
}
}
return "", "", false
}
// Parse reads directive lines off the START of prompt — one per line,
// stopping at the first line that isn't a recognized directive — and
// returns the parsed Options plus the remaining prompt text (clean,
// with the directive lines and any single blank line immediately after
// them removed). A prompt with no leading directives at all returns the
// zero Options and prompt unchanged, byte for byte, so the overwhelming
// majority of turns pay no cost here beyond one cheap prefix check per
// line.
//
// Directives, each on its own line at the very start of the message:
//
// /effort low|medium|high|extra|max — reasoning depth for this turn
// (see pkg/effort); unrecognized
// text is reported back as part
// of the tool result, not silently
// ignored — see Parse's errs return.
// /fast on|off — fast mode for this turn (off is
// the default; "/fast" alone means on)
// /system <instruction text> — adds a live "instruction" system
// entry (agentrun.SystemEntry) for
// this turn onward; repeatable
// /system permission: <text> — same, kind "permission"
// /system budget: <text> — same, kind "budget"
//
// Directives are deliberately NOT required to be the only content of a
// message — "/effort high\n\nnow refactor the billing package" works
// exactly as "/effort high" on its own line followed immediately by the
// real prompt. A message that's directives with nothing else after them
// (clean comes back empty) is treated by the caller the same as any
// other empty prompt.
func Parse(prompt string) (opts Options, clean string) {
lines := strings.Split(prompt, "\n")
i := 0
for ; i < len(lines); i++ {
trimmed := strings.TrimSpace(lines[i])
if trimmed == "" {
// A blank line before any directive is just normal prompt
// formatting, not a directive terminator — only stop
// consuming on a non-directive, non-blank line, or once at
// least one directive has been seen and a blank line
// follows it (handled by the break conditions below).
if opts.HadDirectives {
i++ // swallow the one blank line right after directives
break
}
continue
}
prefix, rest, ok := isDirectiveLine(trimmed)
if !ok {
break
}
opts.HadDirectives = true
switch prefix {
case "/effort":
if lvl, err := effort.Parse(rest); err == nil {
opts.Effort = lvl
}
// An unparseable level is silently dropped rather than
// failing the whole turn over a typo in a directive line —
// application.Service still runs the turn at the Agent's
// default effort. A future structured API surface (the
// proto fields) can report this as a real validation
// error; a best-effort text convention shouldn't block a
// turn over it.
case "/fast":
opts.Fast = parseOnOff(rest, true) // bare "/fast" means on
case "/system":
if entry, ok := parseSystemDirective(rest); ok {
opts.SystemEntries = append(opts.SystemEntries, entry)
}
}
}
if !opts.HadDirectives {
return opts, prompt
}
clean = strings.TrimLeft(strings.Join(lines[i:], "\n"), "\n")
return opts, clean
}
func parseOnOff(s string, defaultWhenEmpty bool) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "":
return defaultWhenEmpty
case "on", "true", "1", "yes":
return true
case "off", "false", "0", "no":
return false
default:
return defaultWhenEmpty
}
}
func parseSystemDirective(rest string) (SystemEntry, bool) {
rest = strings.TrimSpace(rest)
if rest == "" {
return SystemEntry{}, false
}
for _, kind := range []SystemEntryKind{SystemEntryPermission, SystemEntryBudget} {
p := string(kind) + ":"
if strings.HasPrefix(strings.ToLower(rest), p) {
content := strings.TrimSpace(rest[len(p):])
if content == "" {
return SystemEntry{}, false
}
return SystemEntry{Kind: kind, Content: content}, true
}
}
return SystemEntry{Kind: SystemEntryInstruction, Content: rest}, true
}

View File

@@ -0,0 +1,107 @@
package turnopts
import (
"testing"
"enkly/pkg/effort"
)
func TestParse_NoDirectives(t *testing.T) {
prompt := "just refactor the billing package please"
opts, clean := Parse(prompt)
if opts.HadDirectives {
t.Fatal("HadDirectives = true, want false for a plain prompt")
}
if clean != prompt {
t.Fatalf("clean = %q, want unchanged %q", clean, prompt)
}
}
func TestParse_EffortAndRest(t *testing.T) {
opts, clean := Parse("/effort high\n\nnow refactor the billing package")
if !opts.HadDirectives {
t.Fatal("HadDirectives = false, want true")
}
if opts.Effort != effort.High {
t.Fatalf("Effort = %q, want %q", opts.Effort, effort.High)
}
if clean != "now refactor the billing package" {
t.Fatalf("clean = %q", clean)
}
}
func TestParse_FastBare(t *testing.T) {
opts, clean := Parse("/fast\nmake this quick")
if !opts.Fast {
t.Fatal("Fast = false, want true for bare /fast")
}
if clean != "make this quick" {
t.Fatalf("clean = %q", clean)
}
}
func TestParse_FastOff(t *testing.T) {
opts, _ := Parse("/fast off\nhello")
if opts.Fast {
t.Fatal("Fast = true, want false for /fast off")
}
}
func TestParse_SystemEntries(t *testing.T) {
opts, clean := Parse(
"/system never touch the prod/ directory\n" +
"/system permission: you may now run destructive migrations here\n" +
"/system budget: 4 tool calls left\n" +
"go ahead and start",
)
if len(opts.SystemEntries) != 3 {
t.Fatalf("got %d system entries, want 3: %+v", len(opts.SystemEntries), opts.SystemEntries)
}
if opts.SystemEntries[0].Kind != SystemEntryInstruction || opts.SystemEntries[0].Content != "never touch the prod/ directory" {
t.Errorf("entry 0 = %+v", opts.SystemEntries[0])
}
if opts.SystemEntries[1].Kind != SystemEntryPermission || opts.SystemEntries[1].Content != "you may now run destructive migrations here" {
t.Errorf("entry 1 = %+v", opts.SystemEntries[1])
}
if opts.SystemEntries[2].Kind != SystemEntryBudget || opts.SystemEntries[2].Content != "4 tool calls left" {
t.Errorf("entry 2 = %+v", opts.SystemEntries[2])
}
if clean != "go ahead and start" {
t.Fatalf("clean = %q", clean)
}
}
func TestParse_NotADirectiveLookalike(t *testing.T) {
// "/effortless" must NOT be parsed as "/effort" + "less".
prompt := "/effortless attempts at this keep failing, please help"
opts, clean := Parse(prompt)
if opts.HadDirectives {
t.Fatalf("HadDirectives = true for %q, want false", prompt)
}
if clean != prompt {
t.Fatalf("clean = %q, want unchanged", clean)
}
}
func TestParse_UnknownEffortValueIgnored(t *testing.T) {
opts, clean := Parse("/effort super-duper\ndo the thing")
if !opts.HadDirectives {
t.Fatal("HadDirectives = false, want true (the line was still a directive)")
}
if opts.Effort != "" {
t.Fatalf("Effort = %q, want empty for an unrecognized value", opts.Effort)
}
if clean != "do the thing" {
t.Fatalf("clean = %q", clean)
}
}
func TestParse_DirectivesOnlyNoBody(t *testing.T) {
opts, clean := Parse("/effort max\n/fast on")
if !opts.HadDirectives {
t.Fatal("HadDirectives = false, want true")
}
if clean != "" {
t.Fatalf("clean = %q, want empty", clean)
}
}

845
pkg/vllmkit/client.go Normal file
View File

@@ -0,0 +1,845 @@
// Package vllmkit talks to the production vLLM OpenAI-compatible
// inference servers that host Qwen 3.5 (chat + tool calling) and a
// separate embedding model. Shared by the agent service (ChatStream)
// and the knowledge service (Embed) — the same internal/llm.Client the
// monolith had, split out since both of those bounded contexts need it
// and neither should depend on the other.
//
// This package previously spoke Ollama's native /api/chat and
// /api/embed protocol directly against a single local daemon. It now
// speaks the OpenAI /v1/chat/completions and /v1/embeddings wire
// format that `vllm serve` exposes, against two independently scaled,
// independently deployed vLLM servers — one per model, since a single
// vLLM engine process serves exactly one model. See
// ENKLY_VLLM_CHAT_URL / ENKLY_VLLM_EMBED_URL in cmd/server/main.go and
// k8s/11-vllm.yaml for how those servers are configured and deployed.
package vllmkit
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"sort"
"strings"
"sync"
"time"
)
const (
// DefaultChatModel is the served model name the chat vLLM
// deployment must be started with (`vllm serve ... --served-model-name
// qwen3.5`). vLLM matches the request body's "model" field against
// that flag's value, not against a Hugging Face repo id, so this
// is a deployment-chosen alias, not a model identifier — keep it
// in sync with k8s/11-vllm.yaml's --served-model-name.
DefaultChatModel = "qwen3.5"
// DefaultEmbedModel is the served model name for the embedding
// vLLM deployment. vLLM's pooling/embedding model support covers
// nomic-ai/nomic-embed-text-v1.5 (what this deployment actually
// runs; it needs `--trust-remote-code` since Nomic's config uses
// custom modeling code) — small, fast, and a good CPU/GPU-both
// fit. Update the `vector(768)` dimension in schema.sql first if
// you point this at a model with a different output size.
DefaultEmbedModel = "nomic-embed-text"
)
// Options mirrors the subset of the OpenAI-compatible chat-completions
// request body that's worth tuning per call. Unlike Ollama's
// num_ctx/num_gpu/num_thread, context length and GPU/tensor-parallel
// layout are not per-request knobs under vLLM — they're fixed at
// server start (--max-model-len, --tensor-parallel-size,
// --gpu-memory-utilization; see k8s/11-vllm.yaml) and apply to every
// request the deployment serves. Zero values are omitted from the wire
// request, so the server's own defaults apply unless explicitly
// overridden — see the ENKLY_CHAT_* env vars wired up in
// cmd/server/main.go.
type Options struct {
Temperature float64 // sampling temperature (temperature)
TopP float64 // nucleus sampling cutoff (top_p)
MaxTokens int // max tokens to generate this turn (max_tokens); 0 = server default
}
// Client is a thin wrapper around two vLLM OpenAI-compatible HTTP
// APIs: one serving the chat model, one serving the embedding model.
type Client struct {
// ChatBaseURL / EmbedBaseURL point at each vLLM deployment's
// OpenAI-compatible router, e.g. "https://vllm-chat.enkly.org/v1" and
// "https://vllm-embed.enkly.org/v1". They are deliberately separate
// fields (rather than one shared host) because a chat model and
// an embedding model are different vLLM engine processes in
// production — usually on different Services/Deployments with
// different GPU shapes — not two endpoints behind the same daemon
// the way Ollama modeled it.
ChatBaseURL string
EmbedBaseURL string
ChatModel string
EmbedModel string
HTTP *http.Client
// APIKey, when set, is sent as "Authorization: Bearer <APIKey>" on
// every request — vLLM's --api-key flag. Leave empty for a
// cluster-internal deployment with no auth in front of it (the
// Service is only reachable from inside the mesh/namespace); set
// it for anything exposed beyond that boundary. Set via
// ENKLY_VLLM_API_KEY.
APIKey string
// Think controls extended "thinking" on chat requests via vLLM's
// chat_template_kwargs extra-body extension: "" leaves it to the
// chat template's own default (Qwen3's thinking-capable template
// defaults to thinking on), "true"/"false" (and the usual
// on/off/yes/no spellings) force it on/off. A "low"/"medium"/
// "high" effort string (see pkg/effort) is also accepted for
// compatibility with the Anthropic-style effort vocabulary the
// rest of the agent uses — vLLM/Qwen3's reasoning toggle itself is
// a single on/off switch with no server-side notion of graded
// effort, so any non-empty, non-"false" level here just turns
// thinking on; depth is instead shaped by Options.MaxTokens and
// agentrun's own round/compaction budgets (see effort.Profile).
// When on, the reasoning trace streams back separately from the
// answer, in OpenAI's "reasoning_content" delta field
// (ChatEvent.Thinking) — requires the chat deployment to be
// started with a reasoning parser, e.g. `--reasoning-parser
// qwen3`. Set via ENKLY_CHAT_THINK.
Think string
// ChatOptions is sent as the top-level temperature/top_p/max_tokens
// fields of the chat-completions request.
ChatOptions Options
// embedCache memoizes Embed() results within this process's
// lifetime, keyed by (model, text) hash. Embedding the exact same
// string twice is common — repeated search_codebase queries within
// a conversation, or an unchanged chunk re-indexed after a
// no-op edit that rag.Store's content-hash check didn't already
// short-circuit — and skipping the round trip to vLLM for a
// repeat is a pure latency win with no correctness downside, since
// embeddings are a deterministic function of (model, text).
embedCache *embedCache
}
// New builds a Client pointed at the given chat and embed vLLM
// deployments, with connection reuse tuned for the request pattern
// here: a handful of long-lived streaming chat requests plus frequent,
// short embedding calls, each against its own vLLM host. Either URL
// may be left empty if this Client will only ever be used for the
// other call (e.g. the knowledge service never calls ChatStream, so it
// passes "" for chatBaseURL).
func New(chatBaseURL, embedBaseURL, chatModel, embedModel string) *Client {
if chatBaseURL == "" {
chatBaseURL = "http://localhost:8000/v1"
}
if embedBaseURL == "" {
embedBaseURL = "http://localhost:8001/v1"
}
if chatModel == "" {
chatModel = DefaultChatModel
}
if embedModel == "" {
embedModel = DefaultEmbedModel
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 60 * time.Second,
}).DialContext,
// Two vLLM hosts, but chat + embed + concurrent requests (see
// agent.go's parallel tool execution, which can itself fan out
// concurrent search_codebase embed calls) can be in flight
// together — a small pool avoids a fresh TCP+HTTP handshake on
// every call, a real chunk of latency in practice even against
// a fast in-cluster backend.
MaxIdleConns: 64,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
}
return &Client{
ChatBaseURL: chatBaseURL,
EmbedBaseURL: embedBaseURL,
ChatModel: chatModel,
EmbedModel: embedModel,
HTTP: &http.Client{
Transport: transport,
// No overall timeout: chat streams can legitimately run for
// minutes while the agent loop executes several tool calls.
// Callers should thread a context deadline through ctx instead.
},
embedCache: newEmbedCache(500),
}
}
func (c *Client) authHeader(req *http.Request) {
if c.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.APIKey)
}
}
// ---------------------------------------------------------------------
// Chat (streaming, with tool calling) — OpenAI /v1/chat/completions
// ---------------------------------------------------------------------
// wireToolCall is the OpenAI wire shape for a tool call, both on a
// complete assistant message (echoed back into history) and,
// incrementally, on a streamed delta (see toolCallAccumulator).
type wireToolCall struct {
Index *int `json:"index,omitempty"` // present only on streamed deltas
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"` // always "function"
Function wireToolCallFunction `json:"function"`
}
type wireToolCallFunction struct {
Name string `json:"name,omitempty"`
// Arguments is a JSON-encoded string on the wire (OpenAI's
// function-calling contract), not a nested object the way Ollama's
// native API sent it — toWireMessages/fromWireToolCalls marshal
// and unmarshal it at this package's boundary so the rest of the
// codebase still deals in map[string]any.
Arguments string `json:"arguments,omitempty"`
}
type wireMessage struct {
Role string `json:"role"`
Content *string `json:"content"` // nil (omitted) for an assistant message that is tool_calls-only
ToolCalls []wireToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"` // role="tool" only
Name string `json:"name,omitempty"` // role="tool" only; some parsers want the tool name too
}
type wireToolDef struct {
Type string `json:"type"` // always "function"
Function wireToolFunction `json:"function"`
}
type wireToolFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []wireMessage `json:"messages"`
Tools []wireToolDef `json:"tools,omitempty"`
Stream bool `json:"stream"`
// ChatTemplateKwargs is vLLM's extra-body extension point for
// per-request chat-template flags; "enable_thinking" is how
// Qwen3's template (and vLLM's qwen3 reasoning parser) take the
// thinking on/off toggle. Opaque to the OpenAI spec itself, so
// this is marshaled as a bare map rather than a typed field.
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
}
// chatStreamChunk is one Server-Sent-Events "data:" line of an OpenAI
// streaming chat-completions response.
type chatStreamChunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []wireToolCall `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
// Error is set when a vLLM server streams a mid-response failure
// as a normal 200 SSE line rather than an HTTP error status (seen
// e.g. when the tool-call parser can't make sense of the model's
// raw output partway through generation).
Error *wireError `json:"error"`
}
// ErrUpstreamUnavailable wraps a transport-level failure to reach a
// vLLM deployment at all — connection refused, DNS failure, dial/TLS
// timeout — as opposed to vLLM responding with an error status or a
// malformed body. This is exactly the signature of a vllm-chat/
// vllm-embed pod that's Pending, still cold-starting (model load, see
// k8s/11-vllm.yaml's startupProbe), or mid-rollout (Recreate strategy
// briefly has zero replicas) — not a request vLLM itself rejected.
//
// Callers use IsUpstreamUnavailable to tell the two apart: the agent
// service turns this into a short "model is starting up" message for
// the end user instead of a raw dial error (see
// services/agent/internal/application/service.go), and the knowledge
// service's gRPC layer maps it onto codes.Unavailable (safe to retry)
// instead of codes.Internal (opaque failure).
var ErrUpstreamUnavailable = errors.New("vllm: upstream unreachable")
// IsUpstreamUnavailable reports whether err is, or wraps,
// ErrUpstreamUnavailable.
func IsUpstreamUnavailable(err error) bool { return errors.Is(err, ErrUpstreamUnavailable) }
// looksTransient reports whether err is a transport-level failure
// (connection refused, DNS, dial/handshake timeout, connection reset)
// worth a couple of quick retries, as opposed to an error vLLM itself
// returned after accepting the connection.
func looksTransient(err error) bool {
if err == nil {
return false
}
var netErr net.Error
if errors.As(err, &netErr) {
return true // covers dial timeout/refused, DNS failure, TLS handshake failure, etc.
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "no such host") ||
strings.Contains(msg, "connection reset")
}
// retryDelays bounds how long doWithRetry rides out a transient
// failure before giving up: short and few on purpose. This exists to
// smooth over a brief reconnect window — e.g. a Recreate rollout's old
// pod having just terminated — not to mask a genuinely Pending pod,
// which is a scheduling problem no amount of HTTP retrying fixes (see
// k8s/scripts/gpu-preflight.sh for that diagnosis).
var retryDelays = []time.Duration{200 * time.Millisecond, 600 * time.Millisecond}
// doWithRetry POSTs buf as JSON to url, retrying only on a transient
// transport failure (see looksTransient) — never after a response has
// actually been received, since a non-2xx status or a stream already
// in progress isn't safe or meaningful to blindly resend.
func (c *Client) doWithRetry(ctx context.Context, url string, buf []byte, sse bool) (*http.Response, error) {
for attempt := 0; ; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if sse {
req.Header.Set("Accept", "text/event-stream")
}
c.authHeader(req)
resp, err := c.HTTP.Do(req)
if err == nil {
return resp, nil
}
if attempt >= len(retryDelays) || !looksTransient(err) {
return nil, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(retryDelays[attempt]):
}
}
}
type wireError struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
}
func (e *wireError) Error() string {
if e == nil {
return ""
}
if e.Type != "" {
return fmt.Sprintf("%s: %s", e.Type, e.Message)
}
return e.Message
}
// errMalformedToolArgs is wrapped into the error ChatStream reports
// when vLLM's tool-call parser hands back an "arguments" string that
// doesn't parse as JSON. This is this package's own client-side
// detection of a broken tool-call round (vLLM never surfaces this as a
// distinct HTTP/wire error itself) — see IsToolCallParseError.
var errMalformedToolArgs = errors.New("vllm: tool call arguments were not valid JSON")
// toolCallParseErrPattern matches the family of messages vLLM's
// OpenAI-compatible server reports when a configured tool-call parser
// (hermes, llama3_json, qwen3, mistral, …) fails to extract a
// structured call from the model's raw generation — e.g. "Failed to
// parse tool calls", "could not parse function call", "invalid tool
// call arguments produced by model". Server-side wording differs by
// parser plugin and vLLM version, so this matches on the stable,
// shared vocabulary ("tool call" / "function call" alongside "parse"
// or "invalid") rather than one exact phrasing.
func toolCallParseErrPattern(msg string) bool {
msg = strings.ToLower(msg)
mentionsCall := strings.Contains(msg, "tool call") || strings.Contains(msg, "tool_call") ||
strings.Contains(msg, "function call") || strings.Contains(msg, "function_call")
mentionsFailure := strings.Contains(msg, "parse") || strings.Contains(msg, "invalid") || strings.Contains(msg, "malformed")
return mentionsCall && mentionsFailure
}
// IsToolCallParseError reports whether err is a transient failure to
// extract a structured tool call from the model's output — either
// vLLM's own tool-call parser rejecting the raw generation server-side
// (see toolCallParseErrPattern), or this client failing to decode the
// "arguments" JSON string vLLM did return (errMalformedToolArgs). Both
// are almost always a one-off decoding hiccup — something in the
// model's raw output didn't match what the configured parser expected
// — rather than a reason to give up the whole agent turn. Callers
// (agentrun.Agent.Run) use this to retry the round, and if it keeps
// recurring, to simplify the most recent tool results before one final
// retry.
//
// This replaces IsTemplateParseError, which detected the equivalent
// failure mode under Ollama's native chat-template/XML tool-call
// encoding — a protocol vLLM's OpenAI-compatible server doesn't use,
// so that detection logic (XML syntax errors, angle-bracket tag
// sniffing) no longer applies, but the role it plays in the retry loop
// is unchanged.
func IsToolCallParseError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, errMalformedToolArgs) {
return true
}
return toolCallParseErrPattern(err.Error())
}
// thinkWire maps a Think setting onto vLLM's chat_template_kwargs
// extra body. It returns nil when thinking should be left to the
// template's own default (omitting chat_template_kwargs entirely), and
// otherwise a map with "enable_thinking" set to true/false.
//
// override, when non-empty, takes precedence over c.Think for this one
// call — see ChatStream's think parameter. This is what lets a caller
// (agentrun.Agent's ThinkPolicy) dial reasoning on or off on a
// per-round basis — e.g. thinking on for the first, planning round of
// an agentic tool-use turn, thinking off for the rounds that just
// digest a tool result.
func thinkWire(setting, override string) map[string]any {
s := override
if s == "" {
s = setting
}
switch strings.ToLower(strings.TrimSpace(s)) {
case "":
return nil
case "false", "off", "0", "no":
return map[string]any{"enable_thinking": false}
default:
// "true"/"on"/"1"/"yes" and the effort levels ("low", "medium",
// "high", …) all just mean "thinking on" — see Think's doc
// comment for why vLLM/Qwen3 has no graded-effort equivalent.
return map[string]any{"enable_thinking": true}
}
}
// ChatEvent is one item streamed back from ChatStream: either a token of
// assistant content, a batch of tool calls the model wants executed, or
// the terminal done/error signal.
type ChatEvent struct {
Token string
Thinking string // a chunk of the model's reasoning trace (when thinking is on)
ToolCalls []ToolCallRequest
Done bool
Err error
}
// ChatStream sends the full message history (plus tool definitions) to
// the chat vLLM deployment and streams the reply back over the
// returned channel. The channel is closed after a Done or Err event.
//
// think overrides c.Think for this one call when non-empty (see
// thinkWire); pass "" to just use the Client's configured default.
func (c *Client) ChatStream(ctx context.Context, history []Message, tools []ToolDef, think string) (<-chan ChatEvent, error) {
reqBody := chatRequest{
Model: c.ChatModel,
Messages: toWireMessages(history),
Tools: toWireTools(tools),
Stream: true,
ChatTemplateKwargs: thinkWire(c.Think, think),
}
reqBody.Temperature = c.ChatOptions.Temperature
reqBody.TopP = c.ChatOptions.TopP
reqBody.MaxTokens = c.ChatOptions.MaxTokens
buf, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("llm: marshal chat request: %w", err)
}
resp, err := c.doWithRetry(ctx, c.ChatBaseURL+"/chat/completions", buf, true)
if err != nil {
if looksTransient(err) {
return nil, fmt.Errorf("llm: chat request failed (is vLLM running at %s?): %w: %w", c.ChatBaseURL, err, ErrUpstreamUnavailable)
}
return nil, fmt.Errorf("llm: chat request failed (is vLLM running at %s?): %w", c.ChatBaseURL, err)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
body := readErrorBody(resp)
if resp.StatusCode == http.StatusServiceUnavailable {
return nil, fmt.Errorf("llm: vllm returned %s: %s: %w", resp.Status, body, ErrUpstreamUnavailable)
}
return nil, fmt.Errorf("llm: vllm returned %s: %s", resp.Status, body)
}
events := make(chan ChatEvent, 16)
go func() {
defer resp.Body.Close()
defer close(events)
// send delivers an event unless the consumer has gone away (ctx
// cancelled), so this goroutine can never block forever on a
// channel nobody is reading.
send := func(ev ChatEvent) bool {
select {
case events <- ev:
return true
case <-ctx.Done():
return false
}
}
acc := newToolCallAccumulator()
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
sawDone := false
for scanner.Scan() {
line := bytes.TrimSpace(scanner.Bytes())
if len(line) == 0 {
continue
}
data, ok := bytes.CutPrefix(line, []byte("data:"))
if !ok {
// Non-"data:" SSE lines (comments, "event:", blank
// keep-alives) carry nothing this client needs.
continue
}
data = bytes.TrimSpace(data)
if string(data) == "[DONE]" {
sawDone = true
break
}
var chunk chatStreamChunk
if err := json.Unmarshal(data, &chunk); err != nil {
send(ChatEvent{Err: fmt.Errorf("llm: decode stream chunk: %w", err)})
return
}
if chunk.Error != nil {
send(ChatEvent{Err: fmt.Errorf("llm: vllm error: %w", chunk.Error)})
return
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
if choice.Delta.ReasoningContent != "" {
if !send(ChatEvent{Thinking: choice.Delta.ReasoningContent}) {
return
}
}
if choice.Delta.Content != "" {
if !send(ChatEvent{Token: choice.Delta.Content}) {
return
}
}
if len(choice.Delta.ToolCalls) > 0 {
acc.add(choice.Delta.ToolCalls)
}
if choice.FinishReason != nil {
if acc.len() > 0 {
calls, err := acc.finish()
if err != nil {
send(ChatEvent{Err: fmt.Errorf("llm: %w: %w", errMalformedToolArgs, err)})
return
}
if !send(ChatEvent{ToolCalls: calls}) {
return
}
}
sawDone = true
send(ChatEvent{Done: true})
return
}
}
if err := scanner.Err(); err != nil {
send(ChatEvent{Err: fmt.Errorf("llm: reading stream: %w", err)})
return
}
// A [DONE] sentinel with no finish_reason chunk (or EOF with
// neither) means vLLM dropped the stream before completing the
// reply (crash, OOM-kill, network cut) — surface it rather
// than treating a truncated reply as a complete one.
if !sawDone && ctx.Err() == nil {
send(ChatEvent{Err: fmt.Errorf("llm: vllm closed the stream before finishing the reply")})
return
}
if sawDone {
send(ChatEvent{Done: true})
}
}()
return events, nil
}
func readErrorBody(resp *http.Response) string {
body := make([]byte, 2048)
n, _ := resp.Body.Read(body)
raw := body[:n]
var envelope struct {
Error wireError `json:"error"`
}
if err := json.Unmarshal(raw, &envelope); err == nil && envelope.Error.Message != "" {
return envelope.Error.Error()
}
return string(raw)
}
// ---------------------------------------------------------------------
// toolCallAccumulator: reassembles a streamed, index-keyed sequence of
// partial tool_calls deltas into complete calls. The OpenAI streaming
// contract sends a tool call's id/name once (on the chunk that first
// introduces that index) and its "arguments" JSON string in arbitrary-
// length fragments across however many subsequent chunks it takes to
// finish generating them — chunk boundaries do not align with JSON
// token boundaries at all, so nothing is parseable until finish().
// ---------------------------------------------------------------------
type toolCallAccumulator struct {
order []int
byIdx map[int]*accumulatingCall
}
type accumulatingCall struct {
id, name string
args strings.Builder
}
func newToolCallAccumulator() *toolCallAccumulator {
return &toolCallAccumulator{byIdx: map[int]*accumulatingCall{}}
}
func (a *toolCallAccumulator) len() int { return len(a.byIdx) }
func (a *toolCallAccumulator) add(deltas []wireToolCall) {
for _, d := range deltas {
idx := 0
if d.Index != nil {
idx = *d.Index
}
call, ok := a.byIdx[idx]
if !ok {
call = &accumulatingCall{}
a.byIdx[idx] = call
a.order = append(a.order, idx)
}
if d.ID != "" {
call.id = d.ID
}
if d.Function.Name != "" {
call.name = d.Function.Name
}
call.args.WriteString(d.Function.Arguments)
}
}
// finish parses every accumulated call's argument string as JSON and
// returns them in the order their indices first appeared. An empty
// arguments string (a tool with no parameters) is treated as "{}",
// matching what every vLLM tool-call parser actually emits for a
// no-arg call.
func (a *toolCallAccumulator) finish() ([]ToolCallRequest, error) {
sort.Ints(a.order)
out := make([]ToolCallRequest, 0, len(a.order))
for _, idx := range a.order {
call := a.byIdx[idx]
raw := strings.TrimSpace(call.args.String())
if raw == "" {
raw = "{}"
}
var args map[string]any
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return nil, fmt.Errorf("tool %q (id %s): %w", call.name, call.id, err)
}
out = append(out, ToolCallRequest{ID: call.id, Name: call.name, Arguments: args})
}
return out, nil
}
func toWireMessages(history []Message) []wireMessage {
out := make([]wireMessage, 0, len(history))
for _, m := range history {
content := m.Content
wm := wireMessage{
Role: string(m.Role),
Content: &content,
ToolCallID: m.ToolCallID,
Name: m.ToolName,
}
// An assistant message that only carries tool calls (no reply
// text) sends content as null, not "" — some vLLM tool-call
// parsers treat an empty-string content as "the model also
// said something" and choke on it.
if m.Role == RoleAssistant && len(m.ToolCalls) > 0 && m.Content == "" {
wm.Content = nil
}
for _, tc := range m.ToolCalls {
args := tc.Arguments
if args == nil {
args = map[string]any{}
}
argsJSON, err := json.Marshal(args)
if err != nil {
// Arguments is always built from values this process
// itself constructed (see toolcatalog/executor), so a
// marshal failure here would be a programming error,
// not a runtime condition — fall back to "{}" rather
// than drop the whole request.
argsJSON = []byte("{}")
}
wm.ToolCalls = append(wm.ToolCalls, wireToolCall{
ID: tc.ID,
Type: "function",
Function: wireToolCallFunction{Name: tc.Name, Arguments: string(argsJSON)},
})
}
out = append(out, wm)
}
return out
}
func toWireTools(tools []ToolDef) []wireToolDef {
out := make([]wireToolDef, 0, len(tools))
for _, t := range tools {
out = append(out, wireToolDef{
Type: "function",
Function: wireToolFunction{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
return out
}
// ---------------------------------------------------------------------
// Embeddings — OpenAI /v1/embeddings
// ---------------------------------------------------------------------
type embedRequest struct {
Model string `json:"model"`
Input string `json:"input"`
}
type embedResponse struct {
Data []struct {
Embedding []float32 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
}
// Embed returns the embedding vector for a single string, using the
// embed vLLM deployment's /v1/embeddings endpoint. Identical (model,
// text) pairs are served from an in-memory cache instead of
// round-tripping to vLLM again — see the embedCache doc comment on
// Client.
func (c *Client) Embed(ctx context.Context, text string) ([]float32, error) {
if c.embedCache != nil {
if v, ok := c.embedCache.get(c.EmbedModel, text); ok {
return v, nil
}
}
reqBody := embedRequest{Model: c.EmbedModel, Input: text}
buf, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("llm: marshal embed request: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
resp, err := c.doWithRetry(ctx, c.EmbedBaseURL+"/embeddings", buf, false)
if err != nil {
if looksTransient(err) {
return nil, fmt.Errorf("llm: embed request failed (is vLLM running at %s?): %w: %w", c.EmbedBaseURL, err, ErrUpstreamUnavailable)
}
return nil, fmt.Errorf("llm: embed request failed (is vLLM running at %s?): %w", c.EmbedBaseURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body := readErrorBody(resp)
if resp.StatusCode == http.StatusServiceUnavailable {
return nil, fmt.Errorf("llm: vllm embed returned %s: %s: %w", resp.Status, body, ErrUpstreamUnavailable)
}
return nil, fmt.Errorf("llm: vllm embed returned %s: %s", resp.Status, body)
}
var parsed embedResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return nil, fmt.Errorf("llm: decode embed response: %w", err)
}
if len(parsed.Data) == 0 {
return nil, fmt.Errorf("llm: embed response contained no vectors")
}
vec := parsed.Data[0].Embedding
if c.embedCache != nil {
c.embedCache.put(c.EmbedModel, text, vec)
}
return vec, nil
}
// ---------------------------------------------------------------------
// embedCache: small bounded in-memory cache for Embed() results
// ---------------------------------------------------------------------
type embedCache struct {
mu sync.Mutex
cap int
order []string // insertion order, for cheap FIFO eviction
values map[string][]float32
}
func newEmbedCache(capacity int) *embedCache {
return &embedCache{cap: capacity, values: make(map[string][]float32, capacity)}
}
func embedCacheKey(model, text string) string {
h := sha256.Sum256([]byte(model + "\x00" + text))
return hex.EncodeToString(h[:])
}
func (c *embedCache) get(model, text string) ([]float32, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.values[embedCacheKey(model, text)]
return v, ok
}
func (c *embedCache) put(model, text string, vec []float32) {
c.mu.Lock()
defer c.mu.Unlock()
key := embedCacheKey(model, text)
if _, exists := c.values[key]; !exists {
if len(c.order) >= c.cap {
// Evict the oldest entry (simple FIFO — good enough for a
// latency optimization, not a correctness-critical cache).
oldest := c.order[0]
c.order = c.order[1:]
delete(c.values, oldest)
}
c.order = append(c.order, key)
}
c.values[key] = vec
}

210
pkg/vllmkit/client_test.go Normal file
View File

@@ -0,0 +1,210 @@
package vllmkit
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestThinkWire(t *testing.T) {
cases := []struct {
name string
setting, override string
wantEnable *bool // nil means "chat_template_kwargs omitted entirely"
}{
{"both empty", "", "", nil},
{"setting only, bool true", "true", "", boolPtr(true)},
{"setting only, bool false", "false", "", boolPtr(false)},
{"setting only, level", "high", "", boolPtr(true)},
{"override wins over setting", "low", "high", boolPtr(true)},
{"override alone", "", "medium", boolPtr(true)},
{"override explicit false wins", "true", "false", boolPtr(false)},
{"case/space insensitive", " TRUE ", "", boolPtr(true)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := thinkWire(tc.setting, tc.override)
if tc.wantEnable == nil {
if got != nil {
t.Errorf("thinkWire(%q, %q) = %#v, want nil", tc.setting, tc.override, got)
}
return
}
gotEnable, ok := got["enable_thinking"].(bool)
if !ok || gotEnable != *tc.wantEnable {
t.Errorf("thinkWire(%q, %q) = %#v, want enable_thinking=%v", tc.setting, tc.override, got, *tc.wantEnable)
}
})
}
}
func boolPtr(b bool) *bool { return &b }
// TestChatStream_ThinkOverride confirms the per-call think parameter
// reaches the wire request's chat_template_kwargs.enable_thinking,
// taking precedence over the Client's configured default exactly as
// thinkWire specifies — this is the knob agentrun.Agent.ThinkPolicy
// relies on to vary reasoning on or off by round.
func TestChatStream_ThinkOverride(t *testing.T) {
var gotKwargs map[string]any
sawKwargs := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req chatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
gotKwargs = req.ChatTemplateKwargs
sawKwargs = req.ChatTemplateKwargs != nil
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintf(w, "data: %s\n\n", mustJSON(finishedChunk()))
fmt.Fprintf(w, "data: [DONE]\n\n")
}))
defer srv.Close()
run := func(clientThink, callThink string) (map[string]any, bool) {
c := New(srv.URL, "", "test-model", "")
c.Think = clientThink
events, err := c.ChatStream(context.Background(), []Message{{Role: RoleUser, Content: "hi"}}, nil, callThink)
if err != nil {
t.Fatalf("ChatStream: %v", err)
}
for ev := range events {
if ev.Err != nil {
t.Fatalf("stream error: %v", ev.Err)
}
}
return gotKwargs, sawKwargs
}
if got, saw := run("", ""); saw || got != nil {
t.Errorf("client default empty, no override: got kwargs=%#v saw=%v, want omitted", got, saw)
}
if got, _ := run("low", ""); got["enable_thinking"] != true {
t.Errorf("client default only: got kwargs=%#v, want enable_thinking=true", got)
}
if got, _ := run("false", "high"); got["enable_thinking"] != true {
t.Errorf("override should win: got kwargs=%#v, want enable_thinking=true", got)
}
if got, _ := run("true", "false"); got["enable_thinking"] != false {
t.Errorf("override alone (explicit false): got kwargs=%#v, want enable_thinking=false", got)
}
}
func finishedChunk() chatStreamChunk {
var c chatStreamChunk
c.Choices = make([]struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []wireToolCall `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
}, 1)
reason := "stop"
c.Choices[0].FinishReason = &reason
return c
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return string(b)
}
// TestChatStream_StreamsTokens is a sanity check that the SSE-based
// stream reader in ChatStream still behaves with an
// bufio.Scanner-friendly response (guards against accidentally
// breaking the "data: " framing while touching this file).
func TestChatStream_StreamsTokens(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
bw := bufio.NewWriter(w)
write := func(content string, done bool) {
var c chatStreamChunk
c.Choices = make([]struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []wireToolCall `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
}, 1)
c.Choices[0].Delta.Content = content
if done {
reason := "stop"
c.Choices[0].FinishReason = &reason
}
fmt.Fprintf(bw, "data: %s\n\n", mustJSON(c))
}
write("hel", false)
write("lo", true)
fmt.Fprint(bw, "data: [DONE]\n\n")
bw.Flush()
}))
defer srv.Close()
c := New(srv.URL, "", "test-model", "")
events, err := c.ChatStream(context.Background(), []Message{{Role: RoleUser, Content: "hi"}}, nil, "")
if err != nil {
t.Fatalf("ChatStream: %v", err)
}
var got string
for ev := range events {
if ev.Err != nil {
t.Fatalf("stream error: %v", ev.Err)
}
got += ev.Token
}
if got != "hello" {
t.Errorf("got tokens %q, want %q", got, "hello")
}
}
// TestToolCallAccumulator_Streamed confirms incremental, index-keyed
// tool_calls deltas (arguments split across several chunks, as a real
// vLLM server streams them) reassemble into one complete call.
func TestToolCallAccumulator_Streamed(t *testing.T) {
idx := 0
acc := newToolCallAccumulator()
acc.add([]wireToolCall{{Index: &idx, ID: "call_1", Function: wireToolCallFunction{Name: "grep"}}})
acc.add([]wireToolCall{{Index: &idx, Function: wireToolCallFunction{Arguments: `{"pat`}}})
acc.add([]wireToolCall{{Index: &idx, Function: wireToolCallFunction{Arguments: `tern":"foo"}`}}})
calls, err := acc.finish()
if err != nil {
t.Fatalf("finish: %v", err)
}
if len(calls) != 1 {
t.Fatalf("got %d calls, want 1", len(calls))
}
if calls[0].ID != "call_1" || calls[0].Name != "grep" {
t.Errorf("got %+v, want id=call_1 name=grep", calls[0])
}
if calls[0].Arguments["pattern"] != "foo" {
t.Errorf("got arguments %#v, want pattern=foo", calls[0].Arguments)
}
}
func TestIsToolCallParseError(t *testing.T) {
if !IsToolCallParseError(fmt.Errorf("wrap: %w", errMalformedToolArgs)) {
t.Error("wrapped errMalformedToolArgs should be detected")
}
if !IsToolCallParseError(fmt.Errorf("llm: vllm error: BadRequestError: Failed to parse tool calls from model output")) {
t.Error("server-side tool-call parse failure should be detected")
}
if IsToolCallParseError(fmt.Errorf("llm: vllm returned 500 Internal Server Error: out of memory")) {
t.Error("unrelated server error should not be classified as a tool-call parse error")
}
if IsToolCallParseError(nil) {
t.Error("nil error should never be a tool-call parse error")
}
}

73
pkg/vllmkit/types.go Normal file
View File

@@ -0,0 +1,73 @@
package vllmkit
// Role, Message, ToolCallRequest and ToolDef are local equivalents of
// the monolith's internal/models types — kept here, scoped to exactly
// what the OpenAI-compatible chat-completions wire protocol vLLM
// serves needs, so this package has no dependency beyond the Go
// standard library. The agent service (the only caller of ChatStream)
// translates its own domain messages to and from these at the
// boundary; see services/agent/internal/infrastructure/agentrun.
type Role string
const (
RoleUser Role = "user"
RoleAssistant Role = "assistant"
RoleSystem Role = "system"
RoleTool Role = "tool"
)
// Message is one turn in a conversation, as sent to/received from
// vLLM's OpenAI-compatible /v1/chat/completions endpoint.
type Message struct {
Role Role
Content string
ToolName string
Thinking string // reasoning trace (OpenAI wire: "reasoning_content"); sent back to the model, never persisted
// ToolCalls is set on an assistant message that requested one or
// more tool invocations — see ToolCallRequest.
ToolCalls []ToolCallRequest
// ToolCallID identifies, on a role="tool" message, which of the
// preceding assistant message's ToolCalls this is the result for.
// The OpenAI tool-calling contract requires every tool message to
// carry the matching call's id (unlike Ollama's native /api/chat,
// which correlated tool replies positionally with no id at all) —
// vLLM's tool-call parsers (hermes, llama3-json, qwen, …) reject a
// tool message with a missing/mismatched tool_call_id outright, so
// this must be populated whenever Role == RoleTool. Callers get it
// for free: ToolCallRequest.ID below is populated straight off the
// id vLLM assigned the call, and ToolResult carries it forward
// unchanged — see agentrun.Agent.Run, which copies
// ToolCallRequest.ID into the ToolResult it builds and then into
// this field when it appends the reply message.
ToolCallID string
}
// ToolCallRequest is a single tool invocation the model asked for. ID
// is the id vLLM's response assigned the call (OpenAI wire:
// tool_calls[].id) — callers must echo it back as ToolResult's
// ToolCallID so the follow-up tool message can carry Message.ToolCallID.
type ToolCallRequest struct {
ID string
Name string
Arguments map[string]any
}
// ToolResult is what gets fed back to the model after executing a
// ToolCallRequest.
type ToolResult struct {
ToolCallID string
Name string
Content string
IsError bool
}
// ToolDef describes a single callable tool in the JSON-schema shape
// the OpenAI-compatible function-calling API (and therefore vLLM)
// expects.
type ToolDef struct {
Name string
Description string
Parameters map[string]any // JSON Schema object
}