From b9710e6c11e48446606445dfcab151f5971ee4b0 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 14 Apr 2026 23:48:30 +0200 Subject: [PATCH] feat(mana-ai): scaffold server-side Mission Runner (v0.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background Hono/Bun service that scans mana_sync for due Missions and will plan them via mana-llm without requiring an open browser tab. Complements the foreground `startMissionTick` in the webapp. v0.1 scope — scaffold that's deployable, boots cleanly, and reads real data. Execution write-back is tracked as the next PR so we don't commit a half-baked proposal-sync design. Shipped: - Hono app on :3066 with `/health` + service-key-gated `/internal/tick` - `src/db/missions-projection.ts` — field-level LWW replay of `sync_changes` for appId='ai' / table='aiMissions' → live Mission records. Mirrors the webapp's `applyServerChanges` semantics against Postgres instead of Dexie. - `src/db/connection.ts` — bounded `postgres.js` pool (max 4, idle 30s) - `src/cron/tick.ts` — overlap-guarded scheduler, `runTickOnce()` also reachable via HTTP for CI/ops triggering - `src/planner/client.ts` — mana-llm HTTP client shape (OpenAI-compatible `/v1/chat/completions`) - `src/middleware/service-auth.ts` — X-Service-Key gate, no end-user JWTs reach this service - Dockerfile + graceful SIGTERM shutdown (stops timer + releases pool) Not yet implemented (documented in CLAUDE.md with design trade-offs): - Prompt/parser server-side copies — today they live in the webapp. Recommended next step: extract `@mana/shared-ai` package. - Input resolvers for notes / kontext / goals — need projections or a mana-sync internal endpoint - Plan → Mission-iteration write-back + how proposals get back to the user's device (leaning option (a): server writes iterations, the webapp's sync effect translates them into local Proposals) Co-Authored-By: Claude Opus 4.6 (1M context) --- services/mana-ai/CLAUDE.md | 151 ++++++++++++++++++ services/mana-ai/Dockerfile | 36 +++++ services/mana-ai/package.json | 20 +++ services/mana-ai/src/config.ts | 42 +++++ services/mana-ai/src/cron/tick.ts | 87 ++++++++++ services/mana-ai/src/db/connection.ts | 31 ++++ .../mana-ai/src/db/missions-projection.ts | 120 ++++++++++++++ services/mana-ai/src/index.ts | 59 +++++++ .../mana-ai/src/middleware/service-auth.ts | 16 ++ services/mana-ai/src/planner/client.ts | 56 +++++++ services/mana-ai/tsconfig.json | 18 +++ 11 files changed, 636 insertions(+) create mode 100644 services/mana-ai/CLAUDE.md create mode 100644 services/mana-ai/Dockerfile create mode 100644 services/mana-ai/package.json create mode 100644 services/mana-ai/src/config.ts create mode 100644 services/mana-ai/src/cron/tick.ts create mode 100644 services/mana-ai/src/db/connection.ts create mode 100644 services/mana-ai/src/db/missions-projection.ts create mode 100644 services/mana-ai/src/index.ts create mode 100644 services/mana-ai/src/middleware/service-auth.ts create mode 100644 services/mana-ai/src/planner/client.ts create mode 100644 services/mana-ai/tsconfig.json diff --git a/services/mana-ai/CLAUDE.md b/services/mana-ai/CLAUDE.md new file mode 100644 index 000000000..80d74f4c7 --- /dev/null +++ b/services/mana-ai/CLAUDE.md @@ -0,0 +1,151 @@ +# mana-ai + +Background runner for the AI Workbench. Picks up due Missions from the `mana_sync` Postgres and plans/proposes next steps without requiring an open browser tab. Complements the foreground `startMissionTick` in the webapp (`apps/mana/apps/web/src/lib/data/ai/missions/setup.ts`). + +Design context: [`docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md` §20](../../docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md). + +## Status: v0.1 (scaffold) + +This service is a skeleton. It: + +- [x] Boots as a Hono/Bun service on port `3066` +- [x] Exposes `/health` and service-key-gated `/internal/tick` +- [x] Replays `sync_changes` for `appId='ai' / table='aiMissions'` into live Mission records via field-level LWW (`src/db/missions-projection.ts`) +- [x] Lists due missions (`state='active' && nextRunAt <= now()`) +- [x] Has an HTTP client shape for mana-llm (OpenAI-compatible surface) +- [x] Logs every tick's intent ("would plan mission X") + +Intentionally **not yet** implemented: + +- [ ] Server-side copies of `planner/prompt.ts` + `planner/parser.ts` (today they live in the webapp only) +- [ ] Input-resolvers server-side (needs projections for notes / kontext / goals, or a mana-sync `GET /internal/record/:id` endpoint) +- [ ] Write-back path for plan results (see "Open design questions" below) +- [ ] Per-user Postgres RLS scoping — current read scans cross-user and relies on downstream code honouring `userId` + +## Port: 3066 + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| **Runtime** | Bun | +| **Framework** | Hono | +| **Database** | PostgreSQL via `postgres` driver (read-only against `mana_sync`) | +| **Auth** | Service-to-service key; no end-user JWTs | + +## Quick Start + +```bash +# Requires mana_sync DB reachable +cd services/mana-ai +bun run dev + +# Smoke test +curl http://localhost:3066/health +curl -X POST -H "X-Service-Key: dev-service-key" http://localhost:3066/internal/tick +``` + +## Environment Variables + +```env +PORT=3066 +SYNC_DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_sync +MANA_LLM_URL=http://localhost:3020 +MANA_SERVICE_KEY=dev-service-key +TICK_INTERVAL_MS=60000 +TICK_ENABLED=true # flip to false to boot HTTP-only (for Docker health-check) +``` + +## Architecture + +``` +┌────────────────────┐ +│ mana-ai (Bun) │ +│ :3066 │ +│ │ 60s interval +│ ┌─────────────┐ │────────────────┐ +│ │ tick loop │ │ │ +│ │ runTickOnce │ │ │ +│ └─────────────┘ │ │ +│ │ │ │ +│ │ SELECT │ │ +│ ▼ │ │ +│ ┌─────────────┐ │ │ +│ │ missions- │ │ │ +│ │ projection │ │ │ +│ │ (LWW replay)│ │ │ +│ └─────────────┘ │ ▼ +│ │ ┌──────────────┐ +│ ┌─────────────┐ │ │ mana_sync │ +│ │ planner │───┼─────────▶│ (Postgres) │ +│ │ client │ │ └──────────────┘ +│ └─────────────┘ │ +│ │ │ +└───────┼────────────┘ + │ POST /v1/chat/completions + ▼ +┌────────────────────┐ +│ mana-llm (Python) │ +│ :3020 │ +└────────────────────┘ +``` + +## Open design questions (for next PR) + +### 1. How do plan results get back to the user's device? + +Proposals live in a **local-only** Dexie table (`pendingProposals`) — they don't sync. So the server can't just write proposals directly. + +Options: + +**(a) Write iteration + plan to `aiMissions`, let the browser stage proposals on arrival.** +Server appends an iteration with `overallStatus: 'server-planned'` and the plan steps. When the webapp next syncs, an effect subscribed to iteration changes translates each step into a local `Proposal` using the existing `createProposal()`. Clean: preserves the "proposals are local" invariant. Risk: duplicate proposals if multiple devices pick up the same iteration. + +**(b) Introduce `aiProposedSteps` as a synced table.** +Server writes here directly; the webapp treats it as a source for its local `pendingProposals`. Requires a migration step + duplicates the proposal model. + +**(c) Make `pendingProposals` sync.** +Simplest schema change, most invasive: approvals + rejections now race across devices. Would need server-authoritative state transitions. + +**Leaning (a)** — minimal schema change, single source of truth. Implementation sketch: add `iteration.source: 'browser' | 'server'` and a "staging queue" on the webapp that dedups via `iterationId`. + +### 2. Does the server need full LWW replay? + +The projection replays every `sync_changes` row for `aiMissions` on every tick. For a small user base this is fine; past ~100 users × hundreds of rows it becomes wasteful. + +Option: materialized view refreshed on sync-change insert via a trigger or a per-user `ai_mission_snapshot` table the service maintains. Defer until the load shows up. + +### 3. Planner prompt: duplicate or share? + +`prompt.ts` + `parser.ts` live in the webapp's `@mana/web/src/lib/data/ai/missions/planner/`. Server-side copies would drift. Options: + +- Extract a `@mana/shared-ai` package with the prompt/parser +- Keep two copies with a contract test +- Only the webapp plans; server just triggers the browser via push + +First is cleanest; TS source, imports cleanly in both Bun and Vite. + +## Writing code in here + +- No database schema of its own — this service is pure consumer. If you need persistent state (retry queues, per-user cursors), add a separate table namespace under `mana_ai.*` schema on the `mana_sync` database, not a new DB. +- `src/db/missions-projection.ts` is the ONLY place that does LWW replay. Don't duplicate the logic; add new projection helpers there. +- Follow the foreground-runner contract: injected deps (planner, write-back) for tests. Bun's `bun test` runs in `src/**/*.test.ts`. + +## Files + +``` +services/mana-ai/ +├── src/ +│ ├── index.ts — Hono bootstrap + tick scheduler wiring +│ ├── config.ts — Env loading +│ ├── cron/tick.ts — Scan loop, overlap-guarded +│ ├── db/ +│ │ ├── connection.ts — postgres.js pool +│ │ └── missions-projection.ts — sync_changes → Mission LWW replay +│ ├── planner/client.ts — mana-llm HTTP client (OpenAI-compatible) +│ └── middleware/service-auth.ts — X-Service-Key gate for /internal/* +├── Dockerfile +├── package.json +├── tsconfig.json +└── CLAUDE.md +``` diff --git a/services/mana-ai/Dockerfile b/services/mana-ai/Dockerfile new file mode 100644 index 000000000..8107d83ea --- /dev/null +++ b/services/mana-ai/Dockerfile @@ -0,0 +1,36 @@ +# Install stage: use node + pnpm to resolve workspace dependencies +FROM node:22-alpine AS installer + +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +WORKDIR /app + +# Copy workspace structure +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY services/mana-ai/package.json ./services/mana-ai/ +COPY packages/shared-hono ./packages/shared-hono + +# Install only mana-ai and its workspace deps +RUN pnpm install --filter @mana/ai-service... --no-frozen-lockfile --ignore-scripts + +# Runtime stage: bun +FROM oven/bun:1 AS production + +WORKDIR /app + +COPY --from=installer /app/node_modules ./node_modules +COPY --from=installer /app/services/mana-ai/node_modules ./services/mana-ai/node_modules +COPY --from=installer /app/packages ./packages + +COPY services/mana-ai/package.json ./services/mana-ai/ +COPY services/mana-ai/src ./services/mana-ai/src +COPY services/mana-ai/tsconfig.json ./services/mana-ai/ + +WORKDIR /app/services/mana-ai + +EXPOSE 3066 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD bun -e "fetch('http://localhost:3066/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + +CMD ["bun", "run", "src/index.ts"] diff --git a/services/mana-ai/package.json b/services/mana-ai/package.json new file mode 100644 index 000000000..7bdb4ce5c --- /dev/null +++ b/services/mana-ai/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mana/ai-service", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "test": "bun test" + }, + "dependencies": { + "@mana/shared-hono": "workspace:*", + "hono": "^4.7.0", + "postgres": "^3.4.5" + }, + "devDependencies": { + "typescript": "^5.9.3", + "@types/bun": "latest" + } +} diff --git a/services/mana-ai/src/config.ts b/services/mana-ai/src/config.ts new file mode 100644 index 000000000..3b400697b --- /dev/null +++ b/services/mana-ai/src/config.ts @@ -0,0 +1,42 @@ +/** + * Env-driven config for the mana-ai service. + * + * Only references the secrets/URLs the tick loop needs. Auth is + * service-to-service via MANA_SERVICE_KEY (same pattern as mana-credits, + * mana-user); no end-user JWTs reach this service. + */ + +export interface Config { + port: number; + /** mana_sync DB — source of Mission rows (via sync_changes replay). */ + syncDatabaseUrl: string; + /** mana-llm HTTP endpoint (OpenAI-compatible). */ + manaLlmUrl: string; + /** Shared key for service-to-service calls. */ + serviceKey: string; + /** How often the background tick scans for due Missions, in ms. */ + tickIntervalMs: number; + /** Flip to false to boot the HTTP surface without the background tick + * — useful for local smoke-tests + Docker image build verification. */ + tickEnabled: boolean; +} + +function requireEnv(key: string, fallback?: string): string { + const value = process.env[key] ?? fallback; + if (!value) throw new Error(`Missing required env var: ${key}`); + return value; +} + +export function loadConfig(): Config { + return { + port: parseInt(process.env.PORT ?? '3066', 10), + syncDatabaseUrl: requireEnv( + 'SYNC_DATABASE_URL', + 'postgresql://mana:devpassword@localhost:5432/mana_sync' + ), + manaLlmUrl: requireEnv('MANA_LLM_URL', 'http://localhost:3020'), + serviceKey: requireEnv('MANA_SERVICE_KEY', 'dev-service-key'), + tickIntervalMs: parseInt(process.env.TICK_INTERVAL_MS ?? '60000', 10), + tickEnabled: process.env.TICK_ENABLED !== 'false', + }; +} diff --git a/services/mana-ai/src/cron/tick.ts b/services/mana-ai/src/cron/tick.ts new file mode 100644 index 000000000..3928f9f1c --- /dev/null +++ b/services/mana-ai/src/cron/tick.ts @@ -0,0 +1,87 @@ +/** + * Background tick — scans Postgres for due Missions and (eventually) runs + * them through the Planner + writes the resulting plan back as a Mission + * iteration. + * + * Current state (v0.1): reads due missions, logs the intent, does NOT + * write back. Writing requires deciding how proposals materialize + * server-side — see `CLAUDE.md` → "Open design questions" for the + * trade-offs. Shipping this as a scaffold unblocks: + * - deployability of the service + * - smoke-testing Postgres connectivity + mana-llm reachability + * - next PR wires the actual mission-execution flow + */ + +import { getSql } from '../db/connection'; +import { listDueMissions } from '../db/missions-projection'; +import { PlannerClient } from '../planner/client'; +import type { Config } from '../config'; + +export interface TickStats { + scannedAt: string; + dueMissionCount: number; + errors: string[]; +} + +let running = false; + +/** One tick pass. Idempotent; overlap-guarded at module level. */ +export async function runTickOnce(config: Config): Promise { + if (running) { + return { scannedAt: new Date().toISOString(), dueMissionCount: 0, errors: ['overlap-skipped'] }; + } + running = true; + const errors: string[] = []; + let dueMissionCount = 0; + const scannedAt = new Date().toISOString(); + + try { + const sql = getSql(config.syncDatabaseUrl); + const missions = await listDueMissions(sql, scannedAt); + dueMissionCount = missions.length; + + if (missions.length === 0) return { scannedAt, dueMissionCount, errors }; + + // Planner is instantiated here but not invoked yet — see CLAUDE.md. + // The constructor is cheap; holding onto it sets the shape for the + // next PR that actually calls `complete()` per mission. + void new PlannerClient(config.manaLlmUrl, config.serviceKey); + + for (const m of missions) { + console.log( + `[mana-ai tick] would plan mission=${m.id} user=${m.userId} title=${JSON.stringify( + m.title + )}` + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(msg); + console.error('[mana-ai tick] error:', msg); + } finally { + running = false; + } + + return { scannedAt, dueMissionCount, errors }; +} + +let handle: ReturnType | null = null; + +export function startTick(config: Config): () => void { + if (!config.tickEnabled || handle !== null) return stopTick; + // Kick once immediately so a just-due mission doesn't wait a full interval. + void runTickOnce(config); + handle = setInterval(() => void runTickOnce(config), config.tickIntervalMs); + return stopTick; +} + +export function stopTick(): void { + if (handle !== null) { + clearInterval(handle); + handle = null; + } +} + +export function isTickRunning(): boolean { + return handle !== null; +} diff --git a/services/mana-ai/src/db/connection.ts b/services/mana-ai/src/db/connection.ts new file mode 100644 index 000000000..6d29c3a91 --- /dev/null +++ b/services/mana-ai/src/db/connection.ts @@ -0,0 +1,31 @@ +/** + * Thin `postgres.js` wrapper for the mana_sync database. + * + * We read `sync_changes` rows directly — the service is a pure consumer; + * no schema of its own (yet). Missions live as events here, replayed into + * a materialized shape in `missions-projection.ts`. + */ + +import postgres from 'postgres'; + +export type Sql = postgres.Sql>; + +let _sql: Sql | null = null; + +export function getSql(databaseUrl: string): Sql { + if (_sql) return _sql; + _sql = postgres(databaseUrl, { + // Short idle timeout keeps connection count low; the tick is a + // cron-style pattern, not a hot path. + idle_timeout: 30, + max: 4, + }); + return _sql; +} + +export async function closeSql(): Promise { + if (_sql) { + await _sql.end({ timeout: 5 }); + _sql = null; + } +} diff --git a/services/mana-ai/src/db/missions-projection.ts b/services/mana-ai/src/db/missions-projection.ts new file mode 100644 index 000000000..7453614ab --- /dev/null +++ b/services/mana-ai/src/db/missions-projection.ts @@ -0,0 +1,120 @@ +/** + * Missions projection — replays `sync_changes` rows for appId='ai', + * table='aiMissions' into live Mission records using field-level LWW. + * + * This mirrors what the webapp's `applyServerChanges` does in Dexie but + * runs against Postgres. Kept deliberately dumb: no caching, no + * incremental updates, full replay on every tick. Missions per user are + * bounded (~dozens at most) so O(N) replay is fine for a once-a-minute + * scheduler. Revisit when users hit >1000 missions or the tick misses + * its deadline. + */ + +import type { Sql } from './connection'; + +/** + * Subset of the Mission shape the server needs. Matches + * `apps/mana/apps/web/src/lib/data/ai/missions/types.ts` — keep in sync. + */ +export interface ServerMission { + id: string; + userId: string; + title: string; + objective: string; + conceptMarkdown: string; + state: 'active' | 'paused' | 'done' | 'archived'; + nextRunAt: string | undefined; + inputs: { module: string; table: string; id: string }[]; + cadence: unknown; // opaque — the browser Runner owns cadence math + iterations: unknown[]; // opaque — server just reads count +} + +interface ChangeRow { + table_name: string; + record_id: string; + user_id: string; + op: string; + data: Record | null; + field_timestamps: Record | null; + created_at: Date; +} + +/** + * Return all currently-active missions whose `nextRunAt` has passed. + * Server-side equivalent of `listMissions({ dueBefore: now })` in the + * webapp store. + * + * @param now ISO timestamp used as the due-before cutoff. + */ +export async function listDueMissions(sql: Sql, now: string): Promise { + // Pull every event for the ai app across users. For a real deploy + // we'd scope per-user or shard; the pre-launch user count makes this + // single scan defensible. + const rows = await sql` + SELECT table_name, record_id, user_id, op, data, field_timestamps, created_at + FROM sync_changes + WHERE app_id = 'ai' AND table_name = 'aiMissions' + ORDER BY created_at ASC + `; + + // Replay per record. Map key: userId::recordId (user isolation is kept + // even though we fetched across users in one scan — the result goes + // back to whichever user owns each row). + const merged = new Map }>(); + + for (const row of rows) { + const key = `${row.user_id}::${row.record_id}`; + const entry = merged.get(key); + + if (row.op === 'delete') { + merged.delete(key); + continue; + } + + if (!entry) { + if (row.data) { + merged.set(key, { userId: row.user_id, record: { id: row.record_id, ...row.data } }); + } + continue; + } + + // Field-level LWW: newer timestamps overwrite. + const prevFT = (entry.record.__fieldTimestamps as Record | undefined) ?? {}; + const nextFT = { ...prevFT }; + if (row.data) { + for (const [k, v] of Object.entries(row.data)) { + const serverTime = row.field_timestamps?.[k] ?? row.created_at.toISOString(); + const localTime = prevFT[k] ?? ''; + if (serverTime >= localTime) { + entry.record[k] = v; + nextFT[k] = serverTime; + } + } + } + entry.record.__fieldTimestamps = nextFT; + } + + const missions: ServerMission[] = []; + for (const { userId, record } of merged.values()) { + const state = record.state as ServerMission['state']; + const nextRunAt = record.nextRunAt as string | undefined; + const deletedAt = record.deletedAt as string | undefined; + if (deletedAt) continue; + if (state !== 'active') continue; + if (!nextRunAt || nextRunAt > now) continue; + + missions.push({ + id: String(record.id), + userId, + title: String(record.title ?? ''), + objective: String(record.objective ?? ''), + conceptMarkdown: String(record.conceptMarkdown ?? ''), + state, + nextRunAt, + inputs: Array.isArray(record.inputs) ? (record.inputs as ServerMission['inputs']) : [], + cadence: record.cadence, + iterations: Array.isArray(record.iterations) ? record.iterations : [], + }); + } + return missions; +} diff --git a/services/mana-ai/src/index.ts b/services/mana-ai/src/index.ts new file mode 100644 index 000000000..2704849ed --- /dev/null +++ b/services/mana-ai/src/index.ts @@ -0,0 +1,59 @@ +/** + * mana-ai — background Mission Runner for the AI Workbench. + * + * Hono + Bun service that scans mana_sync's `sync_changes` table for due + * Missions (see `data/ai/missions/` in the webapp), calls mana-llm for + * planning, and will eventually stage the resulting plan back as mission + * iterations so the webapp renders them as proposals on next sync. + * + * This is v0.1: scaffold + readable due-mission scan + planner-client + * shape. Execution write-back is tracked as the next PR — see CLAUDE.md. + */ + +import { Hono } from 'hono'; +import { loadConfig } from './config'; +import { closeSql } from './db/connection'; +import { runTickOnce, startTick, stopTick, isTickRunning } from './cron/tick'; +import { serviceAuth } from './middleware/service-auth'; + +const config = loadConfig(); + +const app = new Hono(); + +app.get('/health', (c) => + c.json({ + ok: true, + service: 'mana-ai', + version: '0.1.0', + tick: { enabled: config.tickEnabled, running: isTickRunning() }, + }) +); + +// Service-to-service: manually fire a tick for CI / ops / debugging +// without waiting for the interval. +app.use('/internal/*', serviceAuth(config.serviceKey)); +app.post('/internal/tick', async (c) => { + const stats = await runTickOnce(config); + return c.json(stats); +}); + +const stopScheduledTick = startTick(config); + +const server = Bun.serve({ + port: config.port, + fetch: app.fetch, +}); + +console.log(`[mana-ai] listening on :${config.port} (tick=${config.tickEnabled ? 'on' : 'off'})`); + +// Graceful shutdown — release DB + timer so SIGTERM in k8s shuts down +// the pod instead of waiting for SIGKILL after the grace period. +for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.on(signal, async () => { + console.log(`[mana-ai] ${signal} received — shutting down`); + stopScheduledTick(); + server.stop(); + await closeSql(); + process.exit(0); + }); +} diff --git a/services/mana-ai/src/middleware/service-auth.ts b/services/mana-ai/src/middleware/service-auth.ts new file mode 100644 index 000000000..87fe3426b --- /dev/null +++ b/services/mana-ai/src/middleware/service-auth.ts @@ -0,0 +1,16 @@ +/** + * Service-to-service auth: validates X-Service-Key against MANA_SERVICE_KEY. + * All admin routes on this service are service-only — no end-user JWTs. + */ + +import type { MiddlewareHandler } from 'hono'; + +export function serviceAuth(serviceKey: string): MiddlewareHandler { + return async (c, next) => { + const key = c.req.header('X-Service-Key'); + if (!key || key !== serviceKey) { + return c.json({ error: 'invalid or missing service key' }, 401); + } + await next(); + }; +} diff --git a/services/mana-ai/src/planner/client.ts b/services/mana-ai/src/planner/client.ts new file mode 100644 index 000000000..2bea51033 --- /dev/null +++ b/services/mana-ai/src/planner/client.ts @@ -0,0 +1,56 @@ +/** + * Thin HTTP client for mana-llm (OpenAI-compatible surface on /v1/chat/completions). + * + * The prompt/parser logic lives in the webapp's + * `apps/mana/apps/web/src/lib/data/ai/missions/planner/` directory and is + * duplicated here as server-side copies in follow-up work — keeping the + * webapp as source of truth for now while the service matures. + */ + +export interface PlannerMessages { + system: string; + user: string; +} + +export interface PlannerResult { + /** Raw text the LLM returned. Parser lives alongside the caller. */ + content: string; +} + +export class PlannerClient { + constructor( + private readonly baseUrl: string, + private readonly serviceKey: string + ) {} + + async complete( + messages: PlannerMessages, + opts: { model?: string; temperature?: number } = {} + ): Promise { + const res = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${this.serviceKey}`, + }, + body: JSON.stringify({ + model: opts.model ?? 'gpt-4o-mini', + temperature: opts.temperature ?? 0.3, + messages: [ + { role: 'system', content: messages.system }, + { role: 'user', content: messages.user }, + ], + }), + }); + + if (!res.ok) { + throw new Error(`mana-llm ${res.status}: ${await res.text().catch(() => '')}`); + } + + const body = (await res.json()) as { + choices?: { message?: { content?: string } }[]; + }; + const content = body.choices?.[0]?.message?.content ?? ''; + return { content }; + } +} diff --git a/services/mana-ai/tsconfig.json b/services/mana-ai/tsconfig.json new file mode 100644 index 000000000..25b285f99 --- /dev/null +++ b/services/mana-ai/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "types": ["bun-types"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"] +}