ai: migrate LLM backbone from Kimi CLI to Codex CLI

Retires the Moonshot/Kimi subscription in favour of the already-paid ChatGPT
plan. Both CLI wrappers now run `codex exec`; the kimi-agent container is gone.

adolf-llm + hindsight-llm:
- runKimi -> runCodex (`codex exec --json --skip-git-repo-check`), resume via
  `codex exec resume <thread_id>`.
- MCP moves from a per-session .mcp.json (a workaround for Kimi having no
  --mcp-config-file flag) to a $CODEX_HOME/config.toml generated once at
  startup from shared-mcp.json. Field translation is load-bearing:
  bearerTokenEnvVar -> bearer_token_env_var, enabledTools -> enabled_tools.
- approval_policy="never" + sandbox_mode required, or unattended turns block
  on an approval prompt nobody can answer.

kimi-agent removed. It was the ONLY large-tier deployment behind LiteLLM, so
deleting it outright would have silently degraded every large-tier request to
the local 4B model via the existing fallbacks. tier-large, the auto_router
complex-reasoning route and their fallbacks now point at the codex-backed
adolf-llm wrapper (model_name: codex-agent).

Three environment blockers fixed along the way:
- OpenAI geo-blocks this host (403 unsupported_country_region_territory).
  Both containers now egress via the host xray proxy, with NO_PROXY keeping
  MCP and *.alogins.net traffic off the tunnel.
- node:22-slim ships no system CA store; the Rust codex binary validates TLS
  against it, so every HTTPS call failed with a generic transport error while
  Node's own fetch worked. ca-certificates added to both images.
- `codex exec resume` rejects -C/--cd (plain `codex exec` accepts it), which
  broke follow-up turns while first turns succeeded.

Known regression: Kimi's managed-usage API has no Codex equivalent, so the
/usage route returns 501 and there is no quota probe for the codex model.
The two quota plugins degrade quietly to no output.

Also: stop tracking cognee.env (live LLM + JWT secrets) and gitignore it.
The secrets remain in earlier history and should be rotated.

Verified live: plain turn, SSE streaming, session resume, MCP tool call,
bearer-token MCP call, and completions through both LiteLLM routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Y5QPagv4iun1ghpwM96Ff
This commit is contained in:
2026-08-01 06:13:27 +00:00
parent a27bae828a
commit 9094d71e2f
66 changed files with 653 additions and 851 deletions

3
.gitignore vendored
View File

@@ -16,3 +16,6 @@ __pycache__/
# (e.g. docker-compose.yml.bak-20260704-141509, CLAUDE.md.bak-kb).
*.bak
*.bak-*
# contains live LLM + JWT secrets — never commit
ai/cognee/cognee.env

24
ai/adolf-llm/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:22-slim
# ca-certificates is REQUIRED, not optional: node:22-slim ships no system CA
# store. Node bundles its own so JS fetch works, but the Codex CLI is a Rust
# binary and validates TLS against the system store — without this every HTTPS
# call (incl. `codex login`) dies after the TCP/proxy connect with a generic
# "error sending request". Cost us a long debug; do not drop it.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @openai/codex
WORKDIR /workspace
COPY server.js /app/server.js
# Codex reads auth + config from $CODEX_HOME (default ~/.codex). Pinned
# explicitly so the compose volume mount and the config writer agree.
ENV CODEX_HOME=/root/.codex
EXPOSE 8010
ENTRYPOINT ["node", "/app/server.js"]

View File

@@ -4,11 +4,14 @@ const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const PORT = 8010;
// Both overridable purely so the wrapper can be exercised outside the
// container (the defaults are the in-container values).
const PORT = Number(process.env.PORT) || 8010;
const SHARED_MCP_PATH = process.env.SHARED_MCP_PATH || '/shared-mcp.json';
const MODEL_ID = 'adolf';
const TIMEOUT_MS = 15 * 60 * 1000;
const WORKSPACE = '/workspace';
const WORKSPACE = process.env.WORKSPACE || '/workspace';
const CONV_ROOT = path.join(WORKSPACE, 'conversations');
const STATE_DIR = path.join(WORKSPACE, '.adolf-llm');
const MAP_FILE = path.join(STATE_DIR, 'sessions.json');
@@ -18,51 +21,99 @@ fs.mkdirSync(CONV_ROOT, { recursive: true });
fs.mkdirSync(STATE_DIR, { recursive: true });
// ---------------------------------------------------------------------------
// Shared MCP layer (Gate 1). Kimi Code CLI has NO `--mcp-config-file` flag and
// no `kimi mcp` subcommand; it auto-discovers a project-root `.mcp.json` by
// walking up from its cwd to the nearest `.git` (falling back to cwd itself
// when none is found). So we drop a `.mcp.json` into each session's working
// directory before spawning kimi.
// Shared MCP layer. Unlike Kimi Code CLI (which had no --mcp-config-file flag
// and forced a per-session `.mcp.json` dropped into each working directory),
// Codex reads MCP servers from `$CODEX_HOME/config.toml` under `[mcp_servers.*]`
// tables. So this is now written ONCE at startup instead of per session.
//
// Single source of truth: `/shared-mcp.json` (mounted read-only from the repo
// root's `shared-mcp.json`, the same file P6 wires into OpenClaw's own
// `mcp.servers` registry). Adding a server is then a one-file change — no
// server list is hardcoded here anymore.
// Single source of truth is unchanged: `/shared-mcp.json` (mounted read-only
// from the repo root's `shared-mcp.json`, the same file OpenClaw's own
// `mcp.servers` registry uses). Adding a server stays a one-file change.
//
// Gate-1 transport finding (P5, verified by decompiling the installed
// @moonshot-ai/kimi-code package, packages/agent-core/src/config/schema.ts's
// McpServerConfigSchema): Kimi's own field name for remote MCP servers is
// `transport` (literal "stdio" | "http" | "sse"), not `type`. When `transport`
// is omitted, Kimi's config preprocessor infers it from shape: `command` ->
// "stdio", `url` -> "http" (never "sse" — sse requires an explicit
// `transport: "sse"`). It does NOT recognize a `type` key at all; unknown keys
// are silently stripped by the (non-strict) zod schema.
// OpenClaw's own canonical `mcp.servers` schema (docs/gateway/
// configuration-reference.md) uses different literals for the same
// transport: `transport: "streamable-http"` or `"sse"`, with `type: "http"`
// documented as a *CLI-native alias* that `openclaw mcp set` / `openclaw
// doctor --fix` normalize into canonical `transport: "streamable-http"`.
// So the two consumers disagree on the literal value for HTTP streaming
// ("http" vs "streamable-http") under the same field name `transport` --
// writing `transport` explicitly in shared-mcp.json would satisfy at most one
// side. `type: "http"` is the one shape both sides tolerate today: Kimi
// ignores the unrecognized `type` key and correctly infers transport "http"
// from the `url` field alone; OpenClaw recognizes `type` as its documented
// alias and normalizes it on its own terms (P6 concern, not touched here).
// Hence shared-mcp.json intentionally keeps `"type": "http"` for both cognee
// and openclaw-tools rather than switching to `transport`.
// Transport mapping. shared-mcp.json entries are either:
// { command, args, env } -> stdio server
// { url, type: "http" } -> remote streamable-http server
// Codex expresses stdio servers as `command`/`args`/`env`, and remote servers
// as `url` with an optional `bearer_token_env_var`. It has no `type` key; the
// shape (command vs url) selects the transport, same inference Kimi did. The
// `type: "http"` key that shared-mcp.json carries for OpenClaw's benefit is
// simply not emitted here.
const CODEX_HOME = process.env.CODEX_HOME || '/root/.codex';
let SHARED_MCP_SERVERS = {};
try {
const raw = fs.readFileSync('/shared-mcp.json', 'utf8');
const raw = fs.readFileSync(SHARED_MCP_PATH, 'utf8');
SHARED_MCP_SERVERS = JSON.parse(raw).mcpServers || {};
} catch (err) {
console.error(`shared-mcp.json not loaded (${err.message}); sessions will get no shared MCP servers`);
}
function writeMcpConfig(dir) {
const cfg = { mcpServers: SHARED_MCP_SERVERS };
fs.writeFileSync(path.join(dir, '.mcp.json'), JSON.stringify(cfg, null, 2));
// Minimal TOML emitter — we only ever emit strings, string arrays and flat
// string maps, so a full TOML library would be dead weight.
function tomlString(s) {
return JSON.stringify(String(s)); // TOML basic strings share JSON escaping
}
function tomlValue(v) {
if (Array.isArray(v)) return `[${v.map(tomlString).join(', ')}]`;
return tomlString(v);
}
function renderMcpToml(servers) {
const lines = [
'# GENERATED by adolf-llm from /shared-mcp.json — do not edit by hand.',
'# Regenerated on every container start; manual edits are lost.',
'',
];
for (const [name, cfg] of Object.entries(servers)) {
lines.push(`[mcp_servers.${name}]`);
if (cfg.command) {
lines.push(`command = ${tomlValue(cfg.command)}`);
if (cfg.args && cfg.args.length) lines.push(`args = ${tomlValue(cfg.args)}`);
} else if (cfg.url) {
lines.push(`url = ${tomlValue(cfg.url)}`);
} else {
console.error(`shared-mcp.json: server "${name}" has neither command nor url; skipped`);
lines.pop();
continue;
}
// Field-name translation, Kimi -> Codex. shared-mcp.json is written in
// Kimi/OpenClaw's camelCase dialect; Codex's RawMcpServerConfig uses
// snake_case. Both keys are load-bearing:
// bearerTokenEnvVar -> bearer_token_env_var (agap + marketplace auth;
// without it every tool call on those servers returns HTTP 401)
// enabledTools -> enabled_tools (the capability allow-list
// that ai/agent-registry.yaml's mcp_tool_filter is validated against;
// dropping it would silently widen Adolf's tool access)
if (cfg.bearerTokenEnvVar) {
lines.push(`bearer_token_env_var = ${tomlValue(cfg.bearerTokenEnvVar)}`);
}
if (cfg.enabledTools && cfg.enabledTools.length) {
lines.push(`enabled_tools = ${tomlValue(cfg.enabledTools)}`);
}
if (cfg.env && Object.keys(cfg.env).length) {
lines.push(`[mcp_servers.${name}.env]`);
for (const [k, v] of Object.entries(cfg.env)) lines.push(`${k} = ${tomlValue(v)}`);
}
lines.push('');
}
return lines.join('\n');
}
// Write the Codex config once at startup: MCP servers + the headless-operation
// settings. `approval_policy = "never"` and `sandbox_mode` are load-bearing —
// Codex defaults to asking for approval before running a tool, and nobody is
// there to answer, so without these a turn hangs until the 15-minute timeout
// instead of failing loudly.
function writeCodexConfig() {
fs.mkdirSync(CODEX_HOME, { recursive: true });
const header = [
'approval_policy = "never"',
'sandbox_mode = "danger-full-access"',
'',
].join('\n');
fs.writeFileSync(path.join(CODEX_HOME, 'config.toml'), header + renderMcpToml(SHARED_MCP_SERVERS));
}
writeCodexConfig();
// ---------------------------------------------------------------------------
// Memory lives at the OpenClaw layer, not here (P8). The Adolf gateway loads
@@ -77,7 +128,7 @@ function writeMcpConfig(dir) {
// were deleted when the plugin took over (P8).
// ---------------------------------------------------------------------------
// Persistent conversation -> Kimi session map.
// Persistent conversation -> Codex session (thread) map.
// Primary key: `chat:<chat_id>` parsed from OpenClaw's "Conversation info" block
// (Gate 2). Fallback key: `hist:<sha256(prior history)>` when no chat_id is
// present (e.g. webchat surface / future OpenClaw layout change).
@@ -271,31 +322,70 @@ async function buildPrompt(userMsg, dir) {
}
// ---------------------------------------------------------------------------
// Kimi invocation with REAL streaming. Parses `--output-format stream-json`
// incrementally: each complete stdout line is one JSON object.
// {"role":"assistant","content":"..."} -> emit as a delta
// {"type":"session.resume_hint","session_id":"..."} -> capture session id
// Codex invocation with REAL streaming. Parses `codex exec --json` incrementally:
// each complete stdout line is one JSON event.
//
// Codex 0.146 ships TWO event schemas and which one `--json` emits can change
// between releases, so we handle both rather than pinning to one:
//
// legacy "msg" schema:
// {"id":..,"msg":{"type":"agent_message_delta","delta":"..."}} -> delta
// {"id":..,"msg":{"type":"agent_message","message":"..."}} -> full text
// {"id":..,"msg":{"type":"session_configured","session_id":".."}}-> session id
// newer thread/turn/item schema:
// {"type":"thread.started","thread_id":"..."} -> session id
// {"type":"item.completed","item":{"type":"agent_message",...}} -> full text
//
// Non-assistant items (reasoning, command execution, MCP tool calls) are
// deliberately ignored — Adolf's users see the answer, not the agent's work.
//
// Sequencing note: when a run emits streaming deltas AND a terminal full-text
// message, the full text is the same content already streamed. We therefore
// prefer deltas when any arrived, and fall back to the terminal message only
// when none did — otherwise the reply would be duplicated.
//
// onDelta(chunk) is called per assistant content fragment as it arrives.
// Resolves { text, sessionId } once the process closes.
function runKimi({ prompt, cwd, resumeId, onDelta, signal }) {
function runCodex({ prompt, cwd, resumeId, onDelta, signal }) {
return new Promise((resolve, reject) => {
if (signal?.aborted) { reject(new Error('aborted before start')); return; }
const args = [];
if (resumeId) args.push('-r', resumeId);
args.push('-p', prompt, '--output-format', 'stream-json');
// `exec resume <id>` must come before the prompt; `--skip-git-repo-check`
// is required because session dirs under /workspace are not git repos.
//
// `-C/--cd` is accepted by `codex exec` but NOT by `codex exec resume` —
// passing it there fails with "unexpected argument '-C' found" and breaks
// every follow-up turn while first turns still work. The spawn cwd below
// already puts the process in the right directory, so -C is only an
// explicit belt-and-braces on the fresh-session path.
const args = ['exec'];
if (resumeId) {
args.push('resume', resumeId, '--json', '--skip-git-repo-check', prompt);
} else {
args.push('--json', '--skip-git-repo-check', '-C', cwd, prompt);
}
const child = spawn('kimi', args, { cwd, timeout: TIMEOUT_MS });
// stdin MUST be 'ignore'. With the default 'pipe', codex prints "Reading
// additional input from stdin..." and blocks waiting for EOF on a pipe this
// wrapper never writes to or closes — every turn would hang until the
// 15-minute timeout. (Kimi's CLI did not read stdin, so this is new.)
const child = spawn('codex', args, {
cwd,
timeout: TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'pipe'],
});
let buf = '';
let stderr = '';
const parts = [];
const parts = []; // streamed deltas, in order
let finalText = null; // terminal full-text message, if the run emits one
let errText = null; // structured error reported on the event stream
let sessionId = null;
let settled = false;
let aborted = false;
// If the caller aborts (the gateway/client disconnected — e.g. its idle
// watchdog gave up), kill the child so it doesn't keep grinding an
// orphaned agent turn to completion, wasting Kimi quota and streaming into
// orphaned agent turn to completion, wasting Codex quota and streaming into
// a dead socket. SIGTERM first, hard SIGKILL if it lingers.
const onAbort = () => {
aborted = true;
@@ -309,11 +399,35 @@ function runKimi({ prompt, cwd, resumeId, onDelta, signal }) {
if (!t) return;
let obj;
try { obj = JSON.parse(t); } catch { return; }
if (obj.role === 'assistant' && typeof obj.content === 'string' && obj.content) {
parts.push(obj.content);
if (onDelta) onDelta(obj.content);
// --- legacy "msg" schema -------------------------------------------
const msg = obj.msg;
if (msg && typeof msg.type === 'string') {
if (msg.type === 'agent_message_delta' && typeof msg.delta === 'string' && msg.delta) {
parts.push(msg.delta);
if (onDelta) onDelta(msg.delta);
} else if (msg.type === 'agent_message' && typeof msg.message === 'string' && msg.message) {
finalText = msg.message;
} else if (msg.type === 'session_configured' && msg.session_id) {
sessionId = msg.session_id;
} else if (msg.type === 'error' && msg.message) {
errText = msg.message;
}
return;
}
// --- newer thread/turn/item schema ----------------------------------
if (obj.type === 'thread.started' && obj.thread_id) {
sessionId = obj.thread_id;
} else if (obj.type === 'item.completed' && obj.item) {
const item = obj.item;
if (item.type === 'agent_message') {
const text = typeof item.text === 'string' ? item.text : item.message;
if (typeof text === 'string' && text) finalText = text;
}
} else if (obj.type === 'turn.failed') {
errText = (obj.error && (obj.error.message || obj.error)) || 'turn.failed';
}
if (obj.type === 'session.resume_hint' && obj.session_id) sessionId = obj.session_id;
}
child.stdout.on('data', d => {
@@ -338,11 +452,19 @@ function runKimi({ prompt, cwd, resumeId, onDelta, signal }) {
settled = true;
if (signal) signal.removeEventListener('abort', onAbort);
if (buf) handleLine(buf); // flush any trailing partial line
const text = parts.join('').trim();
// Deltas win when present — the terminal agent_message repeats content
// already streamed to the client. Only fall back to it if nothing streamed.
const streamed = parts.join('').trim();
const text = streamed || (finalText || '').trim();
// A non-streaming run still has to reach the client: emit the terminal
// message as one delta so callers relying on onDelta aren't left empty.
if (!streamed && text && onDelta) onDelta(text);
if (aborted) {
reject(new Error('aborted: client disconnected'));
} else if (!text && code !== 0) {
reject(new Error(`kimi exited ${code}: ${stderr.slice(0, 2000)}`));
reject(new Error(`codex exited ${code}: ${(errText || stderr).slice(0, 2000)}`));
} else if (!text && errText) {
reject(new Error(`codex error: ${errText.slice(0, 2000)}`));
} else {
resolve({ text, sessionId });
}
@@ -352,7 +474,7 @@ function runKimi({ prompt, cwd, resumeId, onDelta, signal }) {
// ---------------------------------------------------------------------------
// One turn: resolve session (chat_id primary, history-hash fallback), persist
// media + .mcp.json, run kimi (streaming through onDelta), record the mapping,
// media, run codex (streaming through onDelta), record the mapping,
// and fire the async cognee ingest. Returns { text }.
async function handleTurn(messages, onDelta, signal) {
const turns = convTurns(messages);
@@ -388,7 +510,7 @@ async function handleTurn(messages, onDelta, signal) {
reseed = prior.length > 0;
}
} else {
// Fallback: no chat_id -> forward history-hash mapping (kimi-agent style).
// Fallback: no chat_id -> forward history-hash mapping.
if (prior.length === 0) {
convId = crypto.randomUUID();
dir = path.join(CONV_ROOT, convId);
@@ -407,7 +529,6 @@ async function handleTurn(messages, onDelta, signal) {
}
fs.mkdirSync(dir, { recursive: true });
writeMcpConfig(dir); // Gate 1: shared MCP via project-root .mcp.json
let prompt;
if (reseed) {
@@ -423,7 +544,7 @@ async function handleTurn(messages, onDelta, signal) {
prompt = await buildPrompt(userMsg, dir);
}
const { text, sessionId } = await runKimi({ prompt, cwd: dir, resumeId, onDelta, signal });
const { text, sessionId } = await runCodex({ prompt, cwd: dir, resumeId, onDelta, signal });
// Record the forward mapping.
const entry = { convId, sessionId: sessionId || resumeId, dir, ts: Date.now() };
@@ -439,180 +560,21 @@ async function handleTurn(messages, onDelta, signal) {
}
// ---------------------------------------------------------------------------
// Kimi quota readout (kb #62). GET /usage — the claude-usage analog for
// Adolf. LLM-free: hits Kimi's own managed-usage endpoint directly, never
// spawns `kimi`. Mirrors the parsing logic of the installed
// @moonshot-ai/kimi-code CLI itself (decompiled from dist/main.mjs's
// parseManagedUsagePayload/toUsageRow/limitLabel/resetHintFrom — same
// endpoint, same response shape) so bucket labels/derivations stay in sync
// with what `kimi` would show via its own /usage-equivalent.
// Quota readout. The Kimi-specific implementation (kb #62/#87) was removed with
// the Codex migration: it authenticated against Kimi's managed-usage API using
// the Kimi CLI's OAuth creds file, and neither the endpoint nor the credential
// exists on this backend. Codex exposes no equivalent machine-readable quota
// endpoint, so /usage now reports "unsupported" rather than inventing numbers.
//
// Token source: the CLI's own OAuth creds file, kept fresh by the running
// `kimi` process (adolf-llm-home volume). We ONLY read the file's live
// access_token and never refresh here. Kimi's OAuth rotates the refresh_token
// on every refresh (single-use), so an independent refresh from this route
// invalidates the refresh_token the CLI's file still holds -> the CLI's next
// refresh fails `invalid_grant` and wipes the whole login (kb#87: this was the
// recurring Adolf logout, incl. the 2026-07-17 06:15 wipe / task #86). Making
// the CLI the sole refresher removes that race.
//
// Cost of that trade, measured 2026-07-22: the access token's `expires_in` is
// 900s, so it is only valid for 15 minutes after the CLI last refreshed it —
// i.e. only within 15 minutes of an actual Adolf turn. Adolf is idle most of
// the day, so a bare read failed far more often than it succeeded, which made
// quota gating effectively blind. Rather than refresh here (see above: that
// wipes the login), /usage now falls back to the LAST GOOD reading, clearly
// labelled `stale` with `as_of` + `age_s` so callers can decide whether it is
// fresh enough. The cache is written on every success and persisted to the
// workspace volume so it survives a container restart. Auth is untouched:
// this route still only ever READS the creds file.
const KIMI_CREDS_PATH = '/root/.kimi-code/credentials/kimi-code.json';
const KIMI_USAGES_URL = 'https://api.kimi.com/coding/v1/usages';
const KIMI_USAGE_CACHE_PATH = '/workspace/.adolf-llm/usage-cache.json';
// Last successful /usage payload, kept in memory and mirrored to disk.
let kimiUsageCache = null;
function readKimiUsageCache() {
if (kimiUsageCache) return kimiUsageCache;
try {
const parsed = JSON.parse(fs.readFileSync(KIMI_USAGE_CACHE_PATH, 'utf8'));
if (parsed && parsed.payload && parsed.cached_at) kimiUsageCache = parsed;
} catch { /* no cache yet, or unreadable — treated as "no cache" */ }
return kimiUsageCache;
}
function writeKimiUsageCache(payload) {
kimiUsageCache = { payload, cached_at: new Date().toISOString() };
try {
fs.mkdirSync(path.dirname(KIMI_USAGE_CACHE_PATH), { recursive: true });
fs.writeFileSync(KIMI_USAGE_CACHE_PATH, JSON.stringify(kimiUsageCache));
} catch { /* cache is best-effort; an unwritable volume must not break /usage */ }
}
async function loadKimiCreds() {
const raw = await fs.promises.readFile(KIMI_CREDS_PATH, 'utf8');
return JSON.parse(raw);
}
// Read the live access_token from the CLI's creds file. We deliberately do NOT
// refresh here (see the note above): the Kimi CLI is the sole refresher, so
// this route can never rotate the single-use refresh_token out from under it.
// A stale file token surfaces as an error -> /usage 502 -> "quota unavailable".
async function getKimiAccessToken() {
const creds = await loadKimiCreds();
const now = Math.floor(Date.now() / 1000);
if (creds.access_token && creds.expires_at && now < creds.expires_at - 30) {
return creds.access_token;
}
throw new Error('kimi access token stale (CLI refreshes on next use); quota temporarily unavailable');
}
async function fetchKimiUsagesRaw() {
const token = await getKimiAccessToken();
const res = await fetch(KIMI_USAGES_URL, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`kimi /usages HTTP ${res.status}: ${text.slice(0, 500)}`);
}
return res.json();
}
function isRecord(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
function toInt(v) {
if (typeof v === 'number') return Number.isFinite(v) ? Math.trunc(v) : null;
if (typeof v === 'string') { const n = Number(v); return Number.isFinite(n) ? Math.trunc(n) : null; }
return null;
}
// Port of the CLI's limitLabel(): prefer an explicit name/title/scope field,
// else derive "<N>h limit" / "<N>m limit" / "<N>d limit" from the window's
// duration+timeUnit.
function kimiLimitLabel(item, detail, window, idx) {
for (const key of ['name', 'title', 'scope']) {
const v = item[key] ?? detail[key];
if (typeof v === 'string' && v) return v;
}
const duration = toInt(window.duration ?? item.duration ?? detail.duration);
const rawUnit = window.timeUnit ?? item.timeUnit ?? detail.timeUnit;
const timeUnit = typeof rawUnit === 'string' ? rawUnit : '';
if (duration !== null) {
if (timeUnit.includes('MINUTE')) {
if (duration >= 60 && duration % 60 === 0) return `${duration / 60}h limit`;
return `${duration}m limit`;
}
if (timeUnit.includes('HOUR')) return `${duration}h limit`;
if (timeUnit.includes('DAY')) return `${duration}d limit`;
return `${duration}s limit`;
}
return `Limit #${idx + 1}`;
}
function kimiResetIso(raw) {
for (const key of ['reset_at', 'resetAt', 'reset_time', 'resetTime']) {
const v = raw[key];
if (typeof v === 'string' && v) return v;
}
return null;
}
// Port of the CLI's toUsageRow(): used = raw.used, or limit-remaining when
// used is absent.
function kimiUsageRow(raw, defaultLabel) {
if (!isRecord(raw)) return null;
const limit = toInt(raw.limit);
let used = toInt(raw.used);
const remaining = toInt(raw.remaining);
if (used === null && remaining !== null && limit !== null) used = limit - remaining;
if (used === null && limit === null) return null;
const name = typeof raw.name === 'string' ? raw.name : (typeof raw.title === 'string' ? raw.title : defaultLabel);
return {
label: name,
used: used ?? 0,
limit: limit ?? 0,
remaining: remaining !== null ? remaining : (limit !== null && used !== null ? limit - used : null),
resets: kimiResetIso(raw),
};
}
function kimiRowOut(row) {
if (!row) return null;
const pct = row.limit > 0 ? Math.round((row.used / row.limit) * 100) : null;
return { pct, used: row.used, limit: row.limit, remaining: row.remaining, resets: row.resets };
}
// Normalize Kimi's /usages payload ({ usage, limits: [...] }) into the
// claude-usage-analog shape: weekly / window_5h / window_7d, each
// pct/used/limit/remaining/resets, plus a raw `limits` passthrough so no
// bucket is lost if label text ever drifts from what we match on below.
function normalizeKimiUsage(payload) {
const rec = isRecord(payload) ? payload : {};
const summaryRow = kimiUsageRow(rec.usage, 'Weekly limit');
const limitRows = [];
const rawLimits = Array.isArray(rec.limits) ? rec.limits : [];
rawLimits.forEach((item, idx) => {
if (!isRecord(item)) return;
const detail = isRecord(item.detail) ? item.detail : item;
const window = isRecord(item.window) ? item.window : {};
const label = kimiLimitLabel(item, detail, window, idx);
const row = kimiUsageRow(detail, label);
if (row) limitRows.push(row);
});
const findByLabel = re => limitRows.find(r => re.test(r.label));
const weekly = summaryRow || findByLabel(/week/i) || null;
const window5h = findByLabel(/^5\s*h(our)?\b|5h limit/i) || null;
const window7d = findByLabel(/^7\s*d(ay)?\b|7d limit/i) || null;
return {
timestamp: new Date().toISOString(),
weekly: kimiRowOut(weekly),
window_5h: kimiRowOut(window5h),
window_7d: kimiRowOut(window7d),
limits: limitRows.map(r => ({ label: r.label, ...kimiRowOut(r) })),
};
}
// The two consumers (kimi-quota-footer-plugin, quota-command-openclaw-plugin)
// both treat a non-OK /usage as "no data" and degrade quietly -- the footer is
// simply omitted. They still need a decision: retire them, or repoint them at
// whatever quota signal the Codex/ChatGPT plan actually exposes.
const USAGE_UNSUPPORTED = {
error: 'usage_unsupported',
backend: 'codex',
detail: 'Codex backend exposes no machine-readable quota endpoint.',
};
// ---------------------------------------------------------------------------
// OpenAI-compatible HTTP surface.
@@ -642,40 +604,17 @@ const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
object: 'list',
data: [{ id: MODEL_ID, object: 'model', owned_by: 'moonshot' }],
data: [{ id: MODEL_ID, object: 'model', owned_by: 'openai' }],
}));
return;
}
if (req.method === 'GET' && req.url === '/usage') {
(async () => {
try {
const raw = await fetchKimiUsagesRaw();
const out = normalizeKimiUsage(raw);
writeKimiUsageCache(out);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ...out, stale: false }));
} catch (err) {
// Token stale (the common case when Adolf has been idle >15min) or Kimi
// unreachable. Serve the last good reading rather than nothing, labelled
// so a caller can reject it if it is too old to gate on.
const cached = readKimiUsageCache();
if (cached) {
const ageS = Math.max(0, Math.round((Date.now() - Date.parse(cached.cached_at)) / 1000));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
...cached.payload,
stale: true,
as_of: cached.cached_at,
age_s: ageS,
stale_reason: String(err.message || err),
}));
return;
}
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(err.message || err) }));
}
})();
// 501 rather than 502: this is not a transient upstream failure, it is a
// capability the codex backend does not have. Consumers already treat any
// non-OK response as "no data" and omit the quota footer.
res.writeHead(501, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(USAGE_UNSUPPORTED));
return;
}
@@ -695,7 +634,7 @@ const server = http.createServer((req, res) => {
const messages = parsed.messages || [];
if (parsed.stream) {
// Real streaming: open SSE, emit role chunk, then forward kimi deltas.
// Real streaming: open SSE, emit role chunk, then forward codex deltas.
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
@@ -706,7 +645,7 @@ const server = http.createServer((req, res) => {
// Heartbeat keepalive: OpenClaw's LLM idle watchdog aborts a turn on
// any >120s gap between SSE stream events (default timeoutSeconds),
// not on total run length. During long thinking/tool/MCP phases Kimi
// not on total run length. During long thinking/tool/MCP phases Codex
// emits stream-json events we don't forward, so the SSE stream can go
// silent well past that window. Every write resets `lastWrite`; a 5s
// ticker emits an empty-content delta once 25s of silence elapses —
@@ -723,7 +662,7 @@ const server = http.createServer((req, res) => {
if (Date.now() - lastWrite >= 25_000) write({ content: '' }, null);
}, 5_000);
// Propagate a client/gateway disconnect down to the kimi child so an
// Propagate a client/gateway disconnect down to the codex child so an
// abandoned turn (e.g. OpenClaw's idle watchdog gave up) is killed
// instead of finishing invisibly and burning quota. `done` guards
// against the normal res.end() 'close' also aborting.

View File

@@ -0,0 +1,26 @@
# adolf-llm — conversational Codex-CLI wrapper (P2, :8010). OpenAI-compatible,
# model id "adolf". Real streaming, chat_id session mapping (1:1 `codex exec
# resume`), media persistence, shared MCP via a generated $CODEX_HOME/config.toml.
# Needs `codex login` credentials seeded into its own home volume.
#
# Migrated off Kimi CLI (2026-07-31) for cost: the Moonshot subscription is
# replaced by the existing ChatGPT plan. NOTE the home volume changed from
# adolf-llm-home (/root/.kimi-code) to adolf-llm-codex-home (/root/.codex) —
# the old volume holds Kimi OAuth creds and can be dropped once this is verified.
#
# Orchestrator: merge this `adolf-llm` service + the two named volumes into
# ai/docker-compose.yml (do NOT edit that file here).
services:
adolf-llm:
build: ./adolf-llm
container_name: adolf-llm
ports:
- "8010:8010"
volumes:
- adolf-llm-workspace:/workspace
- adolf-llm-codex-home:/root/.codex
restart: unless-stopped
volumes:
adolf-llm-workspace:
adolf-llm-codex-home:

View File

@@ -87,7 +87,7 @@ agents:
prompt_source:
current: "container adolf:/home/node/.openclaw/workspace/SOUL.md — lives in the adolf-state Docker VOLUME, live-editable, zero git history. This is the kb#156 migration-debt state; recorded here per kb#134's brief, not migrated."
companion_files: ["AGENTS.md", "IDENTITY.md", "TOOLS.md", "USER.md", "HEARTBEAT.md"] # same workspace, same volume, same debt
target: "git-controlled path once kb#156 lands (e.g. openai/personas/adolf/SOUL.md), deployed read-only into the volume"
target: "git-controlled path once kb#156 lands (e.g. ai/personas/adolf/SOUL.md), deployed read-only into the volume"
trust_class: trusted
preferred_tier: large
backbone: kimi # resolves via model-registry.yaml (adolf-llm Kimi-CLI wrapper endpoint). Swap this ONE field to rebackbone Adolf.
@@ -159,7 +159,7 @@ agents:
openclaw-tools: null # no filter in openclaw.json — already minimal (5/5 kept)
note: >
"scoped core tools" per kb#134's brief, now REAL at both levels: this
IS openai/openclaw.json's live mcp.servers block (server selection)
IS ai/openclaw.json's live mcp.servers block (server selection)
plus its per-server toolFilter.include (tool selection, kb#144). Not
a narrower aspirational allowlist — validate_capability_grants.py
cross-checks both against the live config. LiteLLM virtual-key
@@ -171,7 +171,7 @@ agents:
note: >
Reachable model tiers derived at read time from preferred_tier via
agent_registry.py:litellm_key_spec() — not duplicated here. Actual
virtual-key provisioning happens via openai/provision_litellm_keys.py
virtual-key provisioning happens via ai/provision_litellm_keys.py
against the live LiteLLM proxy; NOT run automatically by this
registry (privileged action, requires the LiteLLM master key —
kb#147 handover step, see task comment).
@@ -187,7 +187,7 @@ agents:
(depends on this registry existing first). The three banks above
are kb#153's target, not current fact for the hooks.
kb#169 (2026-07-26): the SEPARATE raw hindsight MCP tool surface
(mcp.servers.hindsight in adolf/openclaw.json + openai/shared-
(mcp.servers.hindsight in adolf/openclaw.json + ai/shared-
mcp.json — recall/retain/reflect/etc. callable directly by the
model, bypassing #153's interlocutor-scoping entirely) has been
repointed from http://hindsight:8888/mcp/adolf/ (the unpartitioned
@@ -436,7 +436,7 @@ capability_grant_status:
each agent's model allow-list (derived from preferred_tier x
model-registry.yaml routing.tiers, non-metered only unless opted in)
and a default budget from trust_classes[...].default_budget_usd.
openai/provision_litellm_keys.py turns that spec into LiteLLM
ai/provision_litellm_keys.py turns that spec into LiteLLM
/key/generate calls. Verified with --dry-run (prints the exact payload
per agent, no network call) — actually creating keys needs
LITELLM_MASTER_KEY against the live proxy, a privileged write this task
@@ -448,7 +448,7 @@ capability_grant_status:
adolf's real, live MCP surface (git-controlled): its server set matches
tool_allowlist.mcp_servers, and each server's toolFilter.include (added
kb#144 first pass) matches tool_allowlist.mcp_tool_filter — exactly.
openai/validate_capability_grants.py checks this automatically, read-only,
ai/validate_capability_grants.py checks this automatically, read-only,
no live changes. Real and correct for OpenClaw's OWN MCP client surface —
but per the kb#144 first-pass release comment's wire.jsonl proof, this
layer alone does NOT reach the model on Adolf's kimi backbone (see
@@ -462,7 +462,7 @@ capability_grant_status:
wire.jsonl inspection) proved the first pass's openclaw.json-only fix
left Kimi's actual tool bundle unchanged (23/32/29/5, not 14/24/9/5).
Root cause: Kimi CLI auto-discovers a project-root `.mcp.json` that
adolf-llm/server.js's writeMcpConfig() seeds from openai/shared-mcp.json
adolf-llm/server.js's writeMcpConfig() seeds from ai/shared-mcp.json
— a completely separate config from openclaw.json, read by a separate
MCP client (Kimi CLI inside the adolf-llm container, not OpenClaw inside
the adolf container). shared-mcp.json now carries the same per-server
@@ -470,14 +470,14 @@ capability_grant_status:
McpServerCommonFields.enabledTools, applied via computeEnabledNames;
confirmed by decompiling the installed @moonshot-ai/kimi-code package's
dist/main.mjs, both copies of the function/schema, no live container
touched). openai/validate_capability_grants.py now cross-checks THIS
touched). ai/validate_capability_grants.py now cross-checks THIS
file too (load_shared_mcp_enabled_tools), same exit-0-or-fail contract
as the openclaw.json check. marketplace is absent from shared-mcp.json
entirely (pre-existing: Kimi's session never had it) — not asserted by
the validator for that server, by design, not a gap this task opened.
NOT YET ACTIVATED: shared-mcp.json IS bind-mounted read-only into
adolf-llm (`./shared-mcp.json:/shared-mcp.json:ro` in
openai/docker-compose.yml) so the file on disk is already what the
ai/docker-compose.yml) so the file on disk is already what the
container would read — but adolf-llm/server.js loads it ONCE into a
module-level variable at process start (not per-request), so editing the
file alone does not take effect; `docker compose restart adolf-llm` is

View File

@@ -1,5 +1,5 @@
{
"_note": "kb#128 (A2A-16): human-readable source of truth for the auto_router route set. NOT loaded from this path at runtime -- litellm-config.yaml's `auto_router` deployment inlines this same `routes` array as a literal JSON string via litellm_params.auto_router_config. Reason (verified hands-on 2026-07-26 against litellm:main-latest): the auto_router_config_path loader (AutoRouter._load_semantic_routing_routes -> SemanticRouter.from_json) unconditionally builds a raw semantic_router encoder from encoder_type/encoder_name and requires a real provider API key even for a local model name like bge-m3 -- this IS the open Auto Router v2 embedding bug the task brief warned about. The auto_router_config (inline-string) loader (_load_auto_router_routes_from_config_json) only reads the `routes` key and builds Route objects directly, with zero encoder bootstrap -- confirmed working end-to-end: real litellm.embedding(model=ollama/bge-m3) calls, zero metered API spend, 'hi there' -> ollama/gemma3:4b, a refactor/dependency-injection prompt -> kimi-agent. Keep the two `routes` arrays in sync by hand when editing either.",
"_note": "kb#128 (A2A-16): human-readable source of truth for the auto_router route set. NOT loaded from this path at runtime -- litellm-config.yaml's `auto_router` deployment inlines this same `routes` array as a literal JSON string via litellm_params.auto_router_config. Reason (verified hands-on 2026-07-26 against litellm:main-latest): the auto_router_config_path loader (AutoRouter._load_semantic_routing_routes -> SemanticRouter.from_json) unconditionally builds a raw semantic_router encoder from encoder_type/encoder_name and requires a real provider API key even for a local model name like bge-m3 -- this IS the open Auto Router v2 embedding bug the task brief warned about. The auto_router_config (inline-string) loader (_load_auto_router_routes_from_config_json) only reads the `routes` key and builds Route objects directly, with zero encoder bootstrap -- confirmed working end-to-end: real litellm.embedding(model=ollama/bge-m3) calls, zero metered API spend, 'hi there' -> ollama/gemma3:4b, a refactor/dependency-injection prompt -> codex-agent (was kimi-agent until the 2026-08-01 Kimi purge). Keep the two `routes` arrays in sync by hand when editing either.",
"encoder_type": "litellm",
"encoder_name": "bge-m3",
"routes": [
@@ -21,7 +21,7 @@
"score_threshold": 0.5
},
{
"name": "kimi-agent",
"name": "codex-agent",
"description": "Complex reasoning, multi-step planning, coding, or anything needing tool use and deep context.",
"utterances": [
"write a function that parses this log file and extracts errors",

View File

@@ -17,7 +17,7 @@
# (docker exec has access via the mount; no host-side permission needed).
#
# Run every 3 days via root crontab (same schedule as sibling backups), e.g.:
# 0 4 */3 * * /home/alvis/agap_git/openai/backup-hindsight-adolf.sh >> /mnt/backups/hindsight-adolf/backup.log 2>&1
# 0 4 */3 * * /home/alvis/agap_git/ai/backup-hindsight-adolf.sh >> /mnt/backups/hindsight-adolf/backup.log 2>&1
#
# Restore:
# # hindsight (drop+recreate the DB first if restoring into a fresh instance,

View File

@@ -7,7 +7,7 @@
# Backup-freshness monitored via .age items.
#
# Run every 3 days via root crontab (same schedule as vaultwarden/seafile), e.g.:
# 0 3 */3 * * /home/alvis/agap_git/openai/backup-llm-dbs.sh >> /mnt/backups/openai-llm-dbs/backup.log 2>&1
# 0 3 */3 * * /home/alvis/agap_git/ai/backup-llm-dbs.sh >> /mnt/backups/openai-llm-dbs/backup.log 2>&1
#
# Restore (litellm-db example, langfuse-db is identical with its own container/user/db):
# gunzip -c /mnt/backups/openai-llm-dbs/<DATE>/litellm-db.sql.gz | \

View File

@@ -50,7 +50,7 @@ can't go through the agentic CLI).
## Smoke test
```bash
cd /home/alvis/agap_git/openai
cd /home/alvis/agap_git/ai
docker build -t cognee-llm:local ./cognee-llm
docker run --rm -d --name cognee-llm-smoke -p 18011:8011 cognee-llm:local
curl -s http://localhost:18011/v1/models

View File

@@ -1,4 +1,4 @@
# Intended service block for /home/alvis/agap_git/openai/docker-compose.yml.
# Intended service block for /home/alvis/agap_git/ai/docker-compose.yml.
# Not wired in yet (see P3 task note) — orchestrator merges this in and adds
# `cognee-llm-home` to the top-level `volumes:` section.

View File

@@ -1,3 +1,10 @@
# kb#220: dir renamed openai/ -> ai/ (nothing in it is OpenAI). Pin the
# compose project name explicitly so container/network/volume names
# (e.g. openai_adolf-state) stay stable across the rename -- otherwise
# Compose derives the project name from the directory basename and the
# rename would orphan the existing volume/network.
name: openai
services:
litellm-db:
image: postgres:16-alpine
@@ -49,13 +56,12 @@ services:
retries: 5
start_period: 20s
kimi-agent:
build: ./kimi-agent
container_name: kimi-agent
volumes:
- /home/alvis/kimi-workspace:/workspace
- kimi-agent-home:/root/.kimi-code
restart: unless-stopped
# kimi-agent — REMOVED 2026-08-01 (Kimi purge). Was the only large-tier
# deployment behind LiteLLM; `tier-large`, the auto_router complex route and
# their fallbacks now point at the codex-backed adolf-llm wrapper instead
# (litellm-config.yaml model_name: codex-agent). The kimi-agent-home volume
# and /home/alvis/kimi-workspace are left on disk deliberately — drop them
# once the Codex path has proven itself.
langfuse-db:
image: postgres:16-alpine
@@ -75,20 +81,75 @@ services:
retries: 5
start_period: 10s
# kb#148 (A2A-16): Langfuse v3 split the monolith into langfuse-web +
# langfuse-worker, and added ClickHouse (event/analytics store), Redis
# (queue) and S3-compatible blob storage (MinIO here) as hard
# dependencies -- Postgres alone is no longer sufficient, unlike v2.
# NOT YET ACTIVATED: v2's existing trace history (3175+ traces per
# DESIGN-a2a-agents.md §7, confirmed live 2026-07-26) lives only in the
# langfuse-db Postgres volume in v2's schema. Langfuse's official v2->v3
# upgrade path requires running the migration entrypoint once against
# this data (langfuse/langfuse:3's container runs pending Postgres
# migrations automatically on boot, but the ClickHouse backfill of
# historical trace data is a separate, explicit step -- see Langfuse's
# "Upgrade from v2 to v3" guide) BEFORE cutting traffic over, or the old
# traces are stranded. That migration is a live-data operation with real
# downtime and rollback risk, so it is out of scope for an unattended
# edit -- see kb#148's report for the exact handoff commands. New
# volumes (clickhouse/minio/redis below) also need their host dirs
# created + chowned first (root-gated, same pattern as kb#87's
# hindsight-cache dir).
langfuse-worker:
image: docker.io/langfuse/langfuse-worker:3
container_name: langfuse-worker
depends_on: &langfuse-depends-on
langfuse-db:
condition: service_healthy
langfuse-minio:
condition: service_healthy
langfuse-redis:
condition: service_healthy
langfuse-clickhouse:
condition: service_healthy
environment: &langfuse-worker-env
NEXTAUTH_URL: https://lf.alogins.net
DATABASE_URL: postgresql://langfuse:langfuse@langfuse-db:5432/langfuse
SALT: 7927b3b0092afe4542274940b557becea6418a5fed79f7acd25c3a789349fdc9
ENCRYPTION_KEY: 12056e4e3cf5b9d936fedca267d4bd877a4b79fb9ff0ff32859a623d5e96c814
CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000
CLICKHOUSE_URL: http://langfuse-clickhouse:8123
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: f1d3bd6dc01c9741b99c633b2e167d1d
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse
LANGFUSE_S3_EVENT_UPLOAD_REGION: auto
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: minio
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: 078dd39aada907ab40c6a4d581033cfe
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://langfuse-minio:9000
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true"
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: events/
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse
LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: minio
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: 078dd39aada907ab40c6a4d581033cfe
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://langfuse-minio:9000
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true"
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: media/
REDIS_HOST: langfuse-redis
REDIS_PORT: "6379"
REDIS_AUTH: 36471006ce5b95ed4f7fb769788fe91c
restart: always
langfuse:
image: ghcr.io/langfuse/langfuse:2
image: docker.io/langfuse/langfuse:3
container_name: langfuse
depends_on: *langfuse-depends-on
ports:
- "3200:3000"
environment:
- DATABASE_URL=postgresql://langfuse:langfuse@langfuse-db:5432/langfuse
- NEXTAUTH_URL=https://lf.alogins.net
- NEXTAUTH_SECRET=532a746b24ac40afa39f9d317031cab94d4d6881107ea3b1209b28020f1a9761
- SALT=7927b3b0092afe4542274940b557becea6418a5fed79f7acd25c3a789349fdc9
- AUTH_DISABLE_SIGNUP=true
depends_on:
langfuse-db:
condition: service_healthy
<<: *langfuse-worker-env
NEXTAUTH_SECRET: 532a746b24ac40afa39f9d317031cab94d4d6881107ea3b1209b28020f1a9761
AUTH_DISABLE_SIGNUP: "true"
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: https://lf.alogins.net
restart: always
# kb#190: langfuse's Next.js server binds the container's bridge IP,
# NOT 127.0.0.1/localhost (confirmed via `ss -tlnp` inside the
@@ -102,6 +163,59 @@ services:
retries: 5
start_period: 30s
langfuse-clickhouse:
image: docker.io/clickhouse/clickhouse-server:25.12
container_name: langfuse-clickhouse
user: "101:101"
environment:
- CLICKHOUSE_DB=default
- CLICKHOUSE_USER=clickhouse
- CLICKHOUSE_PASSWORD=f1d3bd6dc01c9741b99c633b2e167d1d
volumes:
- /mnt/ssd/dbs/langfuse/clickhouse-data:/var/lib/clickhouse
- /mnt/ssd/dbs/langfuse/clickhouse-logs:/var/log/clickhouse-server
restart: always
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1
interval: 5s
timeout: 5s
retries: 10
start_period: 1s
langfuse-minio:
image: cgr.dev/chainguard/minio
container_name: langfuse-minio
entrypoint: sh
# create the 'langfuse' bucket before starting the service
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
environment:
- MINIO_ROOT_USER=minio
- MINIO_ROOT_PASSWORD=078dd39aada907ab40c6a4d581033cfe
volumes:
- /mnt/ssd/dbs/langfuse/minio:/data
restart: always
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 5s
retries: 5
start_period: 1s
langfuse-redis:
image: docker.io/redis:7
container_name: langfuse-redis
command: >
--requirepass 36471006ce5b95ed4f7fb769788fe91c
--maxmemory-policy noeviction
volumes:
- /mnt/ssd/dbs/langfuse/redis:/data
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 10s
retries: 10
qdrant:
image: qdrant/qdrant
container_name: qdrant
@@ -164,12 +278,12 @@ services:
# gateway config (Matrix channel + allow-list, model provider ->
# adolf-llm:8010, MCP registry, gateway.tools.allow for cron/nodes) is
# version-controlled at agap_git/adolf/openclaw.json (repo root, alongside
# this openai/ project, not nested inside it) and bind-mounted read-only
# this ai/ project, not nested inside it) and bind-mounted read-only
# over the adolf-state volume (see volumes below), so git is the single
# source of truth — not a hand-edited volume file. The volume still
# holds runtime state only (Matrix crypto/devices, credentials, sessions,
# workspace/SOUL.md, logs). Matrix creds and ADOLF_KEY come from
# openai/.env (gitignored, never committed). Source tree: /home/alvis/adolf.
# ai/.env (gitignored, never committed). Source tree: /home/alvis/adolf.
# To change config: edit ../adolf/openclaw.json + restart adolf.
adolf:
build:
@@ -215,53 +329,73 @@ services:
# mcp.servers.agap.headers.Authorization via ${AGAP_MCP_TOKEN}
# substitution, and read directly by the todoist-capture plugin's
# /capture-idea POST. The token must map to agent id `adolf` in
# agap-mcp's AGAP_MCP_AGENT_TOKENS. Sourced from openai/.env
# agap-mcp's AGAP_MCP_AGENT_TOKENS. Sourced from ai/.env
# (gitignored); never inlined here.
- AGAP_MCP_TOKEN=${AGAP_MCP_TOKEN:-}
- TZ=Europe/Riga
volumes:
# Runtime state only (Matrix crypto/devices, credentials, sessions,
# workspace, logs). The gateway config file itself is overlaid below.
# kb#219: permanent host mount, replacing the named Docker volume
# (openai_adolf-state) so Adolf's state is inspectable/backup-able on
# the host like every other Agap service (hindsight, litellm, qdrant,
# langfuse, ... all live under /mnt/ssd/dbs/<service>). Runtime state
# only (Matrix crypto/devices, credentials, sessions, workspace,
# logs). The gateway config file and personas are overlaid below.
# Migration: agap_git/ai/migrate-adolf-state.sh (copies the old
# openai_adolf-state volume here; run + verified before this bind
# mount is activated — see kb#219).
# NOT YET ACTIVE (2026-07-31): migrate-adolf-state.sh has not been run,
# so /mnt/ssd/dbs/adolf/ is empty and binding it starts Adolf with a
# clobbered config. Reverted to the named volume until kb#219's root
# setup + migration is done; re-swap these six lines then.
- adolf-state:/home/node/.openclaw
# kb#219: config binds folded from two scattered locations
# (agap_git/adolf/openclaw.json + four agap_git/ai/*-plugin dirs)
# into one coherent home, /mnt/ssd/dbs/adolf/config/. Git remains the
# single source of truth for content — these are symlinks back to the
# tracked agap_git paths (created by the root setup block in kb#219's
# report), not copies, so "edit the tracked file + restart" still
# applies unchanged. Only the mount *path* changed from five spread
# locations to one directory tree.
#
# Version-controlled OpenClaw gateway config, mounted read-only on top
# of the state volume so it is the single source of truth. The gateway
# of the state mount so it is the single source of truth. The gateway
# reads this JSONC file and snapshots its own .last-good/.rejected
# copies into the volume dir (writable) — it never rewrites this file,
# so read-only is safe. Edit the tracked file + restart to change config;
# runtime/UI edits are intentionally disabled by the ro mount.
# copies into the state dir (writable) — it never rewrites this file,
# so read-only is safe.
- ../adolf/openclaw.json:/home/node/.openclaw/openclaw.json:ro
# quota-command plugin (kb #62) — same read-only-bind-over-volume
# pattern as openclaw.json above, applied to a single external plugin
# dir instead of the whole state tree. Previously the only precedent
# (cognee-memory) was docker cp'd straight into the adolf-state volume
# at runtime with no git backing; this plugin is small enough (no
# node_modules — only Node built-ins/global fetch) to just bind-mount
# its tracked source directly at its extensions/<id> path, so git stays
# the single source of truth the same way it already is for
# openclaw.json. Activated via plugins.entries.quota-command in that file.
# quota-command plugin (kb #62) — same read-only-bind pattern as
# openclaw.json above, applied to a single external plugin dir.
# Activated via plugins.entries.quota-command in openclaw.json.
- ./quota-command-openclaw-plugin:/home/node/.openclaw/extensions/quota-command:ro
# hindsight-memory plugin (kb #75, H3) — same read-only-bind-over-volume
# pattern as quota-command above. Structural successor to cognee-memory
# (still docker cp'd into the adolf-state volume, no git backing; that
# plugin's activation/container is decommissioned in H4, not here).
# Forced hooks (before_prompt_build recall / agent_end retain) against
# the hindsight service (see that service's block below), replacing
# hindsight-memory plugin (kb #75, H3) — same pattern. Forced hooks
# (before_prompt_build recall / agent_end retain) against the
# hindsight service (see that service's block below), replacing
# Cognee as Adolf's memory backend. Activated via
# plugins.entries.hindsight-memory in openclaw.json.
- ./hindsight-openclaw-plugin:/home/node/.openclaw/extensions/hindsight-memory:ro
# kimi-quota-footer plugin (kb #85) — same read-only-bind-over-volume
# pattern as quota-command/hindsight-memory above. Appends the Kimi
# kimi-quota-footer plugin (kb #85) — same pattern. Appends the Kimi
# usage line to every outgoing reply via reply_payload_sending, reusing
# quota-command's adolf-llm:8010/usage route. Activated via
# plugins.entries.kimi-quota-footer in openclaw.json.
- ./kimi-quota-footer-plugin:/home/node/.openclaw/extensions/kimi-quota-footer:ro
# todoist-capture plugin (kb#170 component 1) — same read-only-bind-
# over-volume pattern as quota-command/hindsight-memory/kimi-quota-
# footer above. Registers /idea (native command, zero Kimi calls);
# POSTs to agap-mcp's /capture-idea (see agap-mcp/src/server.js +
# capture.js) which does the actual bge-m3 classify + Todoist create.
# Activated via plugins.entries.todoist-capture in openclaw.json.
# todoist-capture plugin (kb#170 component 1) — same pattern.
# Registers /idea (native command, zero Kimi calls); POSTs to
# agap-mcp's /capture-idea (see agap-mcp/src/server.js + capture.js)
# which does the actual bge-m3 classify + Todoist create. Activated
# via plugins.entries.todoist-capture in openclaw.json.
- ./todoist-capture-plugin:/home/node/.openclaw/extensions/todoist-capture:ro
# kb#219 / kb#156: personas deploy read-only onto the mount from the
# alvis/agent-personas gitea repo (commit 936f655 at time of writing)
# via that repo's deploy/deploy-persona.sh, landing at
# /mnt/ssd/dbs/adolf/personas/adolf/*.md. Overlaid individually onto
# the corresponding workspace/*.md files so they stay read-only from
# Adolf's side and are written only by a git deploy — "who changed
# Adolf's soul" is answerable by `git log` in that repo. USER.md is
# deliberately NOT deployed (alvis, 2026-07-30): Hindsight's per-human
# bank (#153) is the single source of user facts now, USER.md was the
# stale unused template.
# (persona overlays deliberately not mounted until kb#219 lands — the
# personas currently live inside the adolf-state volume's workspace/)
extra_hosts:
- "host.docker.internal:host-gateway"
# mtx.alogins.net's public A record can't hairpin-NAT back through the
@@ -292,16 +426,27 @@ services:
restart: unless-stopped
# hindsight-llm — standalone clone of cognee-llm (kb#76, H4 option B): the
# dedicated Kimi-CLI wrapper that is now Hindsight's LLM, so the whole cognee
# dedicated Codex-CLI wrapper that is now Hindsight's LLM, so the whole cognee
# stack (incl. cognee-llm) can be decommissioned. Own port (:8012) + own
# kimi-code volume; needs a one-time `kimi login` seeded into hindsight-llm-home.
# codex volume; needs a one-time `codex login` seeded into
# hindsight-llm-codex-home. Migrated off Kimi CLI 2026-07-31 for cost.
hindsight-llm:
build: ./hindsight-llm
container_name: hindsight-llm
environment:
# Same OpenAI geo-block workaround as adolf-llm above — see the comment
# there. This wrapper makes no MCP calls, but NO_PROXY still keeps
# container-to-container traffic off the tunnel.
- HTTPS_PROXY=http://host.docker.internal:56928
- HTTP_PROXY=http://host.docker.internal:56928
- NO_PROXY=localhost,127.0.0.1,::1,host.docker.internal,.alogins.net,172.16.0.0/12,10.0.0.0/8,192.168.0.0/16
extra_hosts:
# Needed to reach the host's xray proxy (:56928) for OpenAI egress.
- "host.docker.internal:host-gateway"
ports:
- "8012:8012"
volumes:
- hindsight-llm-home:/root/.kimi-code
- hindsight-llm-codex-home:/root/.codex
restart: unless-stopped
# kb#190: GET /v1/models is a static, no-inference route (see
# hindsight-llm/server.js) -- cheap liveness probe.
@@ -346,43 +491,60 @@ services:
retries: 5
start_period: 30s
# adolf-llm — conversational Kimi-CLI wrapper (:8010), the model backend for
# adolf-llm — conversational Codex-CLI wrapper (:8010), the model backend for
# the Adolf OpenClaw gateway (P2). Real streaming (SSE), chat_id session-keying
# + 1:1 kimi resume, media, per-session .mcp.json sourced from the shared
# shared-mcp.json contract (cognee-mcp P4, openclaw-tools P5). Needs
# `kimi login` in adolf-llm-home.
# + 1:1 `codex exec resume`, media, shared MCP via a generated
# $CODEX_HOME/config.toml sourced from the shared-mcp.json contract
# (cognee-mcp P4, openclaw-tools P5). Needs `codex login` in
# adolf-llm-codex-home.
#
# Migrated off Kimi CLI 2026-07-31 for cost (Moonshot subscription retired in
# favour of the existing ChatGPT plan).
adolf-llm:
build: ./adolf-llm
container_name: adolf-llm
environment:
# marketplace-mcp bearer token (kb#61) -- shared-mcp.json's
# "marketplace" entry references this by name via
# `bearerTokenEnvVar: "MARKETPLACE_MCP_TOKEN"` (Kimi CLI's own field
# for a static bearer token sourced from the environment, confirmed by
# decompiling @moonshot-ai/kimi-code's dist/main.mjs help text). Kimi
# `bearerTokenEnvVar: "MARKETPLACE_MCP_TOKEN"`, which adolf-llm's config
# writer translates to Codex's own `bearer_token_env_var` key. Codex
# reads process.env at request time, so the raw secret never sits in
# the git-tracked shared-mcp.json -- same secret, same env-var pattern
# already used for the `adolf` service's openclaw.json Layer-1 config
# above (${MARKETPLACE_MCP_TOKEN} substitution), sourced from
# openai/.env (gitignored, never committed).
# ai/.env (gitignored, never committed).
- MARKETPLACE_MCP_TOKEN=${MARKETPLACE_MCP_TOKEN:-}
# agap-mcp bearer token (kb#180) -- same env-var pattern, referenced by
# shared-mcp.json's "agap" entry via `bearerTokenEnvVar:
# "AGAP_MCP_TOKEN"`. Without it the Kimi backbone's agap tools all
# "AGAP_MCP_TOKEN"`. Without it the Codex backbone's agap tools all
# fail with HTTP 401 once agap-mcp restarts with auth on.
- AGAP_MCP_TOKEN=${AGAP_MCP_TOKEN:-}
# OpenAI egress proxy (Codex migration, 2026-07-31). OpenAI geo-blocks
# this host outright: a direct call returns HTTP 403
# `unsupported_country_region_territory`, so `codex login` and every model
# call fail without this. Routed through the same xray proxy on the host
# that Claude Code itself uses (:56928, listening on all interfaces);
# reached from the container via the host-gateway alias below.
# Codex is Rust/reqwest, which honours these vars natively.
- HTTPS_PROXY=http://host.docker.internal:56928
- HTTP_PROXY=http://host.docker.internal:56928
# NO_PROXY is load-bearing, not cosmetic: without it ALL egress —
# including MCP calls to hindsight/openclaw-tools/agap-mcp and the local
# *.alogins.net services — would be tunnelled through xray, which is both
# slow and likely to fail. Only OpenAI should take the tunnel.
- NO_PROXY=localhost,127.0.0.1,::1,host.docker.internal,hindsight,openclaw-tools,.alogins.net,172.16.0.0/12,10.0.0.0/8,192.168.0.0/16
ports:
- "8010:8010"
volumes:
- adolf-llm-workspace:/workspace
- adolf-llm-home:/root/.kimi-code
- adolf-llm-codex-home:/root/.codex
- ./shared-mcp.json:/shared-mcp.json:ro
extra_hosts:
# Needed to reach kanboard-mcp-adolf (:3104, network_mode: host, outside
# this compose project's network) via shared-mcp.json's "kanboard"
# entry — same host-gateway trick used by adolf/cognee/pipecat above.
- "host.docker.internal:host-gateway"
# Local *.alogins.net web services: the Kimi CLI's own web-fetch tool
# Local *.alogins.net web services: the Codex CLI's own web-fetch tool
# runs IN THIS container, so it needs the same hairpin-NAT dodge as the
# adolf gateway (the public A record can't loop back through the router).
# Route to the host gateway where Caddy terminates TLS on :443.
@@ -390,7 +552,7 @@ services:
- "wiki.alogins.net:host-gateway"
restart: unless-stopped
# kb#190: GET /v1/models is a static, no-inference route (see
# adolf-llm/server.js) -- cheap liveness probe, no Kimi call/quota use.
# adolf-llm/server.js) -- cheap liveness probe, no Codex call/quota use.
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:8010/v1/models').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 15s
@@ -547,8 +709,22 @@ services:
restart: unless-stopped
volumes:
# No longer mounted by any service (kimi-agent removed 2026-08-01). Left
# declared so the volume survives as a rollback source; `docker volume rm`
# is a human decision after a soak period.
kimi-agent-home:
# kb#219: no longer mounted by the adolf service (replaced by the
# /mnt/ssd/dbs/adolf/state bind mount above). Left declared, not removed,
# so the volume itself survives as a rollback source until a human
# explicitly `docker volume rm adolf-state` after a soak period — see the
# rollback procedure in the kb#219 report. Removing this declaration is
# a later cleanup step, not part of this migration.
adolf-state:
hindsight-llm-home:
# Replaces hindsight-llm-home (/root/.kimi-code) at the Codex migration; the
# old volume still holds the Kimi login until this is verified.
hindsight-llm-codex-home:
adolf-llm-workspace:
adolf-llm-home:
# Replaces adolf-llm-home (/root/.kimi-code) at the Codex migration. The old
# volume still exists and holds the Kimi OAuth login; drop it once the Codex
# backend is verified working.
adolf-llm-codex-home:

View File

@@ -0,0 +1,19 @@
FROM node:22-slim
# Required: node:22-slim has no system CA store and the Codex CLI is a Rust
# binary that validates TLS against it. See adolf-llm/Dockerfile for detail.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @openai/codex
WORKDIR /workspace
COPY server.js /app/server.js
ENV CODEX_HOME=/root/.codex
EXPOSE 8012
ENTRYPOINT ["node", "/app/server.js"]

View File

@@ -66,17 +66,26 @@ function withSlot(fn) {
});
}
// --- kimi invocation: stateless one-shot, no resume --------------------------
// Fresh temp dir per call, NO -r/-S session flag, discard the dir after.
// Returns the assembled text from --output-format stream-json:
// {"role":"assistant","content":"..."}
// (reuses the same parse core as kimi-agent/server.js's runKimi, minus the
// resume/session-id bookkeeping that wrapper needs and this one deliberately
// does not).
function runKimi({ prompt, cwd }) {
// --- codex invocation: stateless one-shot, no resume -------------------------
// Fresh temp dir per call, no `exec resume`, discard the dir after.
// Parses `codex exec --json`, which ships two event schemas depending on
// release (see adolf-llm/server.js for the same dual handling):
// legacy: {"msg":{"type":"agent_message_delta","delta":"..."}}
// {"msg":{"type":"agent_message","message":"..."}}
// newer: {"type":"item.completed","item":{"type":"agent_message","text":...}}
// Deltas are preferred when present; the terminal full message is a fallback,
// never an addition, or the extraction output would be duplicated.
function runCodex({ prompt, cwd }) {
return new Promise((resolve, reject) => {
const args = ['-p', prompt, '--output-format', 'stream-json'];
const child = spawn('kimi', args, { cwd, timeout: TIMEOUT_MS });
// --skip-git-repo-check: the per-call temp dir is not a git repo.
const args = ['exec', '--json', '--skip-git-repo-check', '-C', cwd, prompt];
// stdin 'ignore': otherwise codex waits for EOF on an unused pipe and the
// call hangs until TIMEOUT_MS. See adolf-llm/server.js for detail.
const child = spawn('codex', args, {
cwd,
timeout: TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
@@ -86,16 +95,26 @@ function runKimi({ prompt, cwd }) {
child.on('error', reject);
child.on('close', code => {
const parts = [];
let finalText = null;
for (const line of stdout.split('\n')) {
const t = line.trim();
if (!t) continue;
let obj;
try { obj = JSON.parse(t); } catch { continue; }
if (obj.role === 'assistant' && obj.content) parts.push(obj.content);
const msg = obj.msg;
if (msg && typeof msg.type === 'string') {
if (msg.type === 'agent_message_delta' && msg.delta) parts.push(msg.delta);
else if (msg.type === 'agent_message' && msg.message) finalText = msg.message;
continue;
}
if (obj.type === 'item.completed' && obj.item && obj.item.type === 'agent_message') {
const text = typeof obj.item.text === 'string' ? obj.item.text : obj.item.message;
if (text) finalText = text;
}
}
const text = parts.join('').trim();
const text = (parts.join('').trim() || (finalText || '').trim());
if (!text && code !== 0) {
reject(new Error(`kimi exited ${code}: ${stderr.slice(0, 2000)}`));
reject(new Error(`codex exited ${code}: ${stderr.slice(0, 2000)}`));
} else {
resolve(text);
}
@@ -109,7 +128,7 @@ async function handleTurn(messages) {
const dir = path.join(WORKSPACE, reqId);
fs.mkdirSync(dir, { recursive: true });
try {
return await withSlot(() => runKimi({ prompt, cwd: dir }));
return await withSlot(() => runCodex({ prompt, cwd: dir }));
} finally {
// Stateless one-shot: nothing about this call is meant to survive it, so
// the temp dir is discarded unconditionally, success or failure.
@@ -138,7 +157,7 @@ const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
object: 'list',
data: [{ id: MODEL_ID, object: 'model', owned_by: 'moonshot' }],
data: [{ id: MODEL_ID, object: 'model', owned_by: 'openai' }],
}));
return;
}

View File

@@ -27,11 +27,16 @@ model_list:
# paid-fallback footgun. Per design §3a (no metered API by default), do not
# re-add a metered deployment without an explicit opt-in decision.
# Kimi Code CLI agent (own container, own Moonshot/Kimi subscription via `kimi login`)
- model_name: kimi-agent
# Codex CLI agent. Replaces the retired `kimi-agent` container (2026-08-01,
# Kimi purge): that was the ONLY large-tier deployment behind LiteLLM, so
# deleting it outright would have silently degraded every large-tier request
# to the local 4B model via the fallbacks below. Repointed at the codex-backed
# adolf-llm wrapper (:8010, OpenAI-compatible, model id "adolf") instead of
# standing up a third CLI container with its own login.
- model_name: codex-agent
litellm_params:
model: openai/kimi-agent
api_base: http://kimi-agent:8000/v1
model: openai/adolf
api_base: http://adolf-llm:8010/v1
api_key: dummy
# ── raw model exposure ─────────────────────────────────────────────────
@@ -135,9 +140,9 @@ model_list:
# target = constraint-set ("any large model"), not a specific backbone.
# Two litellm_params entries sharing one model_name = a LiteLLM deployment
# group; the router load-balances/fails-over across them. tier-large lists
# kimi-agent FIRST so it's preferred, with local-small as the in-group
# codex-agent FIRST so it's preferred, with local-small as the in-group
# failover partner -- this is also what the fallbacks: block below promotes
# to an explicit, auditable Kimi-429-degrades-to-local path (design §2
# to an explicit, auditable quota-429-degrades-to-local path (design §2
# theorem 2: quota-gated a(t)=0 -> park/degrade, never fail).
# tier-small mirrors model-registry.yaml's routing.tiers.small = [local-small].
- model_name: tier-small
@@ -147,8 +152,8 @@ model_list:
- model_name: tier-large
litellm_params:
model: openai/kimi-agent
api_base: http://kimi-agent:8000/v1
model: openai/adolf
api_base: http://adolf-llm:8010/v1
api_key: dummy
# ── kb#128: Auto Router v2 -- embedding-based classification on the LOCAL
@@ -181,7 +186,7 @@ model_list:
{"name": "ollama/gemma3:4b", "description": "Simple, short, low-stakes requests -- greetings, quick factual lookups, formatting, one-line questions.",
"utterances": ["hi", "hello", "what time is it", "what's the weather", "thanks", "what does this word mean", "summarize this in one sentence", "give me a quick yes or no", "format this as a list", "what is 2 plus 2"],
"score_threshold": 0.5},
{"name": "kimi-agent", "description": "Complex reasoning, multi-step planning, coding, or anything needing tool use and deep context.",
{"name": "codex-agent", "description": "Complex reasoning, multi-step planning, coding, or anything needing tool use and deep context.",
"utterances": ["write a function that parses this log file and extracts errors", "refactor this class to use dependency injection", "think through the tradeoffs of these two architectures step by step", "debug why this docker container keeps crashing", "plan out the migration from cognee to hindsight across five tasks", "analyze this design document and find inconsistencies", "write a SQL query that joins these three tables and aggregates by month", "review this pull request for security issues"],
"score_threshold": 0.5}
]}
@@ -211,16 +216,31 @@ litellm_settings:
success_callback: ["langfuse"]
failure_callback: ["langfuse"]
drop_params: true
# kb#148 (A2A-16): per-agent attribution + KB-task granularity in Langfuse.
# `user_api_key_alias` is populated automatically by LiteLLM from the
# calling virtual key (kb#128 provisioned one per agent with key_alias set
# to the agent id -- adolf/claude-coder/torgash/researcher), so every
# trace is tagged with its agent for free as soon as callers use their
# per-agent key. `agent`, `task-id` and `queue` are NOT auto-populated --
# callers must pass them explicitly as
# `extra_body={"metadata": {"agent": "...", "task-id": "...", "queue": "..."}}`
# (OpenAI-SDK-style) or the LiteLLM-native `metadata` field on the request;
# LiteLLM copies matching keys straight onto the Langfuse trace as tags.
# Wiring individual callers (adolf-llm, kimi-agent wrapper, thin workers)
# to actually send that metadata is separate follow-up work, out of this
# task's declared scope (docker-compose.yml + litellm-config.yaml only) --
# flagged in the kb#148 report as adjacent work.
langfuse_default_tags: ["agent", "task-id", "queue", "user_api_key_alias"]
fallbacks:
- deepseek/deepseek-r1:free: ["ollama/qwen3.5:4b"]
# kb#128 acceptance: "a forced Kimi 429 degrades cleanly". kimi-agent is
# the only Kimi deployment actually routed through LiteLLM today (the
# `kimi` model-registry id is called directly via the adolf-llm/
# kb#128 acceptance: "a forced 429 degrades cleanly". codex-agent is the
# only large deployment routed through LiteLLM today (the `codex`
# model-registry id is also called directly via the adolf-llm/
# hindsight-llm wrappers, outside LiteLLM by design -- see model-
# registry.yaml's kimi entry). Both the raw deployment and the tier-large
# registry.yaml's codex entry). Both the raw deployment and the tier-large
# pool degrade to the free local-small model on 429/quota-exhaustion
# rather than failing the caller.
- kimi-agent: ["ollama/gemma3:4b"]
- codex-agent: ["ollama/gemma3:4b"]
- tier-large: ["tier-small"]
# auto_router's embedding path is the one with the open bug report
# (design §3a) -- if it errors, fail over to the zero-API-call heuristic

View File

@@ -144,8 +144,8 @@ echo "== Migration copy verified OK =="
echo "Source volume '$SRC_VOLUME' left untouched (not deleted, not modified)."
echo "Next steps (NOT done by this script -- human-supervised, see kb#219 report):"
echo " 1. Apply the docker-compose.yml bind-mount diff for the 'adolf' service."
echo " 2. docker compose -f openai/docker-compose.yml config -q # validate"
echo " 3. docker compose -f openai/docker-compose.yml up -d adolf # recreates container on new mount"
echo " 2. docker compose -f ai/docker-compose.yml config -q # validate"
echo " 3. docker compose -f ai/docker-compose.yml up -d adolf # recreates container on new mount"
echo " 4. Verify: docker inspect adolf shows /mnt/ssd/dbs/adolf/state, not the volume;"
echo " Matrix session survives (no re-login), memory/config/persona intact."
echo " 5. Only after a soak period: docker volume rm $SRC_VOLUME"

View File

@@ -28,7 +28,8 @@
# consolidation/reflect all route here as of 2026-07-26)
# - judge -> id: paid-fallback (metered; see kb#164 for the fact that
# the no-metered-API constraint has no runtime enforcement yet)
# - kimi-agent -> id: kimi-agent (own container, live; see below)
# - codex-agent -> id: codex-agent (LiteLLM-routed large deployment; see
# below. Was kimi-agent + its own container until the 2026-08-01 purge)
# - bge-m3 -> id: bge-m3 (kb#164, 2026-07-26: wired into litellm-config
# .yaml pointing at ollama on 11436, the real embedder/routing
# classifier; litellm_model_name below updated from null to "bge-m3")
@@ -64,62 +65,71 @@
schema_version: 1
models:
# ── kimi — main reasoning ─────────────────────────────────────────────
# Flat Moonshot/Kimi subscription via `kimi login`, wrapped by two
# independent Kimi-CLI containers (own OAuth creds volume each). Not
# behind LiteLLM today — callers hit the wrapper HTTP endpoints directly.
- id: kimi
role: "main reasoning (adolf-llm / hindsight-llm Kimi-CLI wrappers)"
# ── codex — main reasoning ─────────────────────────────────────────────
# Flat ChatGPT subscription via `codex login`, wrapped by two independent
# Codex-CLI containers (own creds volume each). Not behind LiteLLM today —
# callers hit the wrapper HTTP endpoints directly.
#
# Migrated from Kimi CLI 2026-07-31 for cost: this retires the separate
# Moonshot subscription in favour of the already-paid ChatGPT plan. The id
# changed `kimi` -> `codex`; routing.tiers below refers to it by id.
- id: codex
role: "main reasoning (adolf-llm / hindsight-llm Codex-CLI wrappers)"
litellm_model_name: null
endpoints:
- name: adolf-llm
purpose: "Adolf's conversational backbone"
url: "http://adolf-llm:8010"
# Route retained but returns HTTP 501 since the Codex migration —
# no machine-readable quota on this backend. See quota: below.
usage_url: "http://localhost:8010/usage"
- name: hindsight-llm
purpose: "Hindsight's structured-extraction LLM (HINDSIGHT_API_LLM_MODEL)"
url: "http://hindsight-llm:8012/v1"
model_name: "openai/hindsight-llm"
tier: large
context_tokens: 200000 # Moonshot Kimi K2 context window; re-verify if the CLI's pinned model changes
context_tokens: 400000 # GPT-5-Codex context window; re-verify if the CLI's pinned model changes
tool_use_quality: high
lifecycle: quota-gated
quota:
probe_command: ["kimi-usage", "--compact"]
windows:
- name: 5h
field: "window_5h.pct" # adolf-llm server.js normalizeKimiUsage() field name
approx_limit: "~60 msgs/5h"
- name: weekly
field: "weekly.pct"
approx_limit: "~300 msgs/wk"
# UNRESOLVED at the Codex migration (2026-07-31): the Kimi backend had a
# machine-readable managed-usage API that adolf-llm's /usage route
# normalized into these fields. Codex exposes no equivalent endpoint, so
# /usage now returns HTTP 501 and there is currently NO quota probe for
# this model. Governor quota-gating on `codex` is therefore blind — it
# will not see the ChatGPT plan's rate limits until a signal is found.
probe_command: null
windows: []
threshold_pct: 95
gpu_residency: null
cost_class: subscription # flat-rate, not metered — quota is the constraint, not spend
metered: false
opt_in_required: false
# ── kimi-agent — own container, oO-adjacent Kimi CLI wrapper ───────────
# Distinct from `kimi` above: this is a third Kimi-CLI container
# (openai/kimi-agent/, own Moonshot/Kimi subscription via `kimi login`,
# own docker-compose service `kimi-agent`) that IS routed through
# LiteLLM today (litellm-config.yaml model_name: kimi-agent ->
# openai/kimi-agent -> http://kimi-agent:8000/v1). Documented here per
# kb#195 coverage audit; deliberately NOT added to routing.tiers in this
# pass (that would change litellm_key_spec() grants, out of scope for a
# docs-alignment task) — no agent is currently opted into it.
- id: kimi-agent
role: "Kimi-CLI wrapper, own container (openai/kimi-agent/) — purpose/consumer not yet documented outside this registry"
litellm_model_name: "kimi-agent" # openai/litellm-config.yaml model_list entry
# ── codex-agent — the LiteLLM-routed large deployment ──────────────────
# Replaces the retired `kimi-agent` container (2026-08-01 Kimi purge). That
# container was the ONLY large-tier deployment behind LiteLLM, backing
# `tier-large`, the auto_router's complex-reasoning route and their
# fallbacks — removing it without a replacement would have silently degraded
# every large-tier request to the local 4B model. Rather than stand up a
# third CLI container with its own login, this now points at the existing
# codex-backed adolf-llm wrapper (:8010, model id "adolf").
#
# Same underlying ChatGPT subscription as `codex` above — the two ids differ
# only in call path (this one via LiteLLM, `codex` direct to the wrappers),
# so their quota is shared and neither has a probe.
- id: codex-agent
role: "LiteLLM-routed large deployment; proxies to the codex-backed adolf-llm wrapper"
litellm_model_name: "codex-agent" # openai/litellm-config.yaml model_list entry
endpoints:
- name: kimi-agent
url: "http://kimi-agent:8000/v1"
- name: adolf-llm
url: "http://adolf-llm:8010/v1"
tier: large
context_tokens: 200000 # same Moonshot Kimi K2 CLI as `kimi`; re-verify if the CLI's pinned model changes
context_tokens: 400000 # GPT-5-Codex context window; re-verify if the CLI's pinned model changes
tool_use_quality: high
lifecycle: quota-gated
quota:
probe_command: null # not yet wired to a probe; own subscription, same caveat as `kimi`
probe_command: null # no machine-readable quota on the Codex backend — see `codex`
windows: []
threshold_pct: null
gpu_residency: null
@@ -256,10 +266,14 @@ gpu_residency_policy:
routing:
tiers:
small: [local-small]
# paid-fallback listed as a large-tier candidate AFTER kimi so resolve()
# can fail over to it when kimi's a(t)=0 (quota parked) — but only for a
# paid-fallback listed as a large-tier candidate AFTER codex so resolve()
# can fail over to it when codex's a(t)=0 (quota parked) — but only for a
# caller that both passes allow_metered=True AND appears in
# metered_opt_in below. With metered_opt_in empty (the shipped default)
# resolve() skips it unconditionally, so it stays unreachable.
large: [kimi, paid-fallback]
#
# NB: with the Codex migration there is no quota probe (see the `codex`
# entry), so a(t) never reads as parked and this failover cannot trigger
# on quota today.
large: [codex, paid-fallback]
metered_opt_in: [] # e.g. ["agent:torgash"] once a human explicitly opts a specific virtual key in

View File

@@ -25,8 +25,8 @@ callers two things instead:
Usage (library):
from model_registry import load_registry, resolve, to_probe_config, preload_check
reg = load_registry()
model = resolve(reg, tier="large") # -> the "kimi" entry
cfg = to_probe_config(reg, "kimi") # -> kb_worker probe config dict
model = resolve(reg, tier="large") # -> the "codex" entry
cfg = to_probe_config(reg, "codex") # -> kb_worker probe config dict
ok, reason = preload_check(reg, "local-small", headroom_mb=1900)
Usage (CLI, for manual verification):

View File

@@ -37,7 +37,7 @@ const FETCH_TIMEOUT_MS = 15000; // bge-m3 embed + Todoist create can take a few
// no longer an open REST endpoint (it never should have been: it reaches
// Todoist writes from any LAN peer). This plugin runs inside the adolf
// container, so it presents Adolf's own agap-mcp bearer token, injected as
// AGAP_MCP_TOKEN by openai/docker-compose.yml from .env (never inlined
// AGAP_MCP_TOKEN by ai/docker-compose.yml from .env (never inlined
// here). If the var is unset the request goes out unauthenticated and
// agap-mcp answers 401 — a visible failure of /idea, not a silent one.
const AGAP_MCP_TOKEN = process.env.AGAP_MCP_TOKEN || "";

View File

@@ -1,11 +0,0 @@
FROM node:22-slim
RUN npm install -g @moonshot-ai/kimi-code
WORKDIR /workspace
COPY server.js /app/server.js
EXPOSE 8010
ENTRYPOINT ["node", "/app/server.js"]

View File

@@ -1,20 +0,0 @@
# adolf-llm — conversational Kimi-CLI wrapper (P2, :8010). OpenAI-compatible,
# model id "adolf". Real streaming, chat_id session mapping (1:1 kimi -r resume),
# media persistence, shared .mcp.json per session. cognee/MCP wiring is stubbed
# until P4/P5. Needs `kimi login` credentials seeded into its own home volume.
# Orchestrator: merge this `adolf-llm` service + the two named volumes into
# openai/docker-compose.yml (do NOT edit that file here).
services:
adolf-llm:
build: ./adolf-llm
container_name: adolf-llm
ports:
- "8010:8010"
volumes:
- adolf-llm-workspace:/workspace
- adolf-llm-home:/root/.kimi-code
restart: unless-stopped
volumes:
adolf-llm-workspace:
adolf-llm-home:

View File

@@ -1,149 +0,0 @@
# Adolf P4 — Cognee memory service config (mounted at /app/.env in the
# `cognee` container; matches upstream's own docker-compose `.env` pattern).
# cognee-mcp does NOT need this file — it runs in API mode (see
# service-block.yml) and only ever talks HTTP to `cognee`, never touching
# these DBs directly.
ENV=local
DEBUG=false
LOG_LEVEL=INFO
CORS_ALLOWED_ORIGINS=*
###############################################################################
# LLM — cognee runs on the Kimi subscription via the `cognee-llm` wrapper
# (:8011, built in P3). This is the intended backbone: the whole reason
# cognee-llm exists is to be cognee's LLM on the flat Kimi subscription (no
# per-token cost), consistent with adolf-llm doing the same for the assistant.
#
# Tradeoff (SPIKE-FINDINGS gate 5, accepted): the agentic CLI adds latency
# (~5s floor + ~22-24s/structured call) and runs on a single-seat subscription,
# so batch cognify is slower than a raw API. cognee-llm bounds concurrency
# (MAX_CONCURRENCY=3) to protect the account. If cognify throughput ever
# becomes a problem, the LiteLLM route below is the documented fallback.
#
# Requires: `kimi login` seeded into the `cognee-llm-home` volume (same as
# adolf-llm/kimi-agent).
###############################################################################
LLM_PROVIDER=openai
LLM_MODEL=openai/cognee-llm
# Must include /v1 — cognee's OpenAI-compatible LLM adapter passes this
# straight through to litellm as api_base and litellm appends
# "/chat/completions" verbatim (no path normalization). Without /v1 this hits
# http://cognee-llm:8011/chat/completions, which 404s (cognee-llm only serves
# /v1/chat/completions and /v1/models) — confirmed 2026-07-05 during the P4
# smoke test (litellm.NotFoundError: Error code 404 - 'not found').
LLM_ENDPOINT=http://cognee-llm:8011/v1
LLM_API_KEY=sk-cognee-llm-local
# Force instructor's plain JSON-in-content mode instead of its default
# tool-calling mode. cognee-llm's Kimi CLI wrapper is a text-only pass-through
# (no real OpenAI function/tool-calling support — it just returns
# {"content": "..."}), so instructor's default mode for the "openai" provider
# (tool-calling, since no explicit LLM_INSTRUCTOR_MODE means it never applies
# json_schema_mode either) fails with "Instructor does not support multiple
# tool calls, use List[Model] instead" — confirmed 2026-07-05 during the P4
# smoke test. json_mode matches cognee-llm's own documented behavior
# (STRUCTURED_SYSTEM_PREAMBLE: "When asked for JSON, output raw JSON only").
LLM_INSTRUCTOR_MODE=json_mode
# Fallback only (NOT the default) — route cognify's LLM to a LiteLLM model if
# the Kimi CLI path is ever too slow under batch load. Requires a working
# LiteLLM general model (fix judge's ANTHROPIC_API_KEY or a local qwen's port):
#LLM_MODEL=openai/judge
#LLM_ENDPOINT=http://litellm:4000
###############################################################################
# Embeddings — ollama directly (P4 blocker #1 resolution, per orchestrator:
# "use ollama directly"). LiteLLM's `embedder` route was dead (port bug), so
# rather than fix that indirection we go straight to ollama's own dedicated
# embedding-engine implementation (OllamaEmbeddingEngine, verified present in
# cognee 1.2.2's infra/databases/vector/embeddings/).
#
# Ollama lives in a SEPARATE compose project (not on this `openai` network),
# reachable from containers only via host.docker.internal — hence
# extra_hosts: host.docker.internal:host-gateway on the cognee service in
# docker-compose.yml. Verified 2026-07-05: `curl host.docker.internal:11436`
# from a throwaway container with that extra_hosts entry returns 200.
#
# EMBEDDING_ENDPOINT must be the FULL endpoint URL including path —
# OllamaEmbeddingEngine POSTs directly to whatever EMBEDDING_ENDPOINT is (its
# own default is "http://localhost:11434/api/embed"), unlike the
# openai_compatible engine which appends its own path onto a base URL. Ollama's
# native /api/embed (batch endpoint, not the singular /api/embeddings) returns
# {"embeddings": [[...]]}; the engine handles that key.
#
# Swapped nomic-embed-text (768-d) -> bge-m3 (1024-d, multilingual, GPU-served)
# 2026-07-06 [Adolf kb#60]. bge-m3 pulled into the same :11436 ollama; tested
# directly against :11436 -> 1024-dim vector, confirmed working. cognee's
# Qdrant collections were all still 768-d (a handful of P4 smoke-test points
# only — "pineapple-7742"/"p4 deployment smoke test" fixtures, no real
# conversation data; adolf-llm's cogneeSearch/cogneeAdd are still stubs and
# have never actually written to cognee), so the stale 768-d collections were
# dropped rather than migrated — cognee recreates them at the new dimension
# on first write.
###############################################################################
EMBEDDING_PROVIDER=ollama
EMBEDDING_MODEL=bge-m3
EMBEDDING_ENDPOINT=http://host.docker.internal:11436/api/embed
EMBEDDING_DIMENSIONS=1024
HUGGINGFACE_TOKENIZER=BAAI/bge-m3
###############################################################################
# Graph store — SPIKE-FINDINGS gate 4: Kuzu embedded, not Neo4j.
# This is cognee's own default; listed explicitly for clarity.
###############################################################################
GRAPH_DATABASE_PROVIDER=kuzu
GRAPH_DATASET_DATABASE_HANDLER=kuzu
###############################################################################
# Vector store — Qdrant (existing infra, :6333). Community adapter installed
# via the custom Dockerfile in this directory (see comments there).
###############################################################################
VECTOR_DB_PROVIDER=qdrant
VECTOR_DB_URL=http://qdrant:6333
VECTOR_DB_KEY=
VECTOR_DATASET_DATABASE_HANDLER=qdrant
###############################################################################
# Relational metadata DB (cognee's own bookkeeping, not the memory graph).
###############################################################################
DB_PROVIDER=sqlite
DB_NAME=cognee_db
###############################################################################
# Storage paths — persisted under /mnt/ssd/dbs/cognee/ on the host (see
# service-block.yml volume mounts to /data and /system).
###############################################################################
DATA_ROOT_DIRECTORY=/data
SYSTEM_ROOT_DIRECTORY=/system
###############################################################################
# Single-user/single-agent posture. Adolf is one Matrix bot (SPIKE-FINDINGS
# gate 4's own reasoning: no multi-tenant/concurrent-writer need at this
# scale). Scoping happens at the *dataset* level (one dataset per OpenClaw
# chat_id — see P4 report), not via cognee's own per-user auth/isolation
# machinery, so we skip that machinery rather than bootstrap a default user
# just to satisfy it.
#
# ENABLE_BACKEND_ACCESS_CONTROL=true (cognee's own default) would give each
# (user, dataset) pair a fully isolated Kuzu+vector store, but *requires*
# authentication (REQUIRE_AUTHENTICATION=false is ignored when this is true)
# - extra machinery (default user bootstrap, token plumbing into cognee-mcp)
# for no real benefit in a single-owner home deployment. With it off, all
# datasets share one graph/vector backend; dataset_name/datasets filters on
# remember/recall/forget still scope top-level data points per conversation,
# with one documented caveat: GRAPH_COMPLETION search can traverse into
# nodes from other datasets. Acceptable for one person's own conversation
# threads; revisit (flip this flag + bootstrap a default user) if that
# leakage ever matters.
###############################################################################
ENABLE_BACKEND_ACCESS_CONTROL=False
REQUIRE_AUTHENTICATION=False
# Only exercised if the above is ever flipped to true.
FASTAPI_USERS_JWT_SECRET=059bd0fdd9cecc46d055cf589d4275bd34c0fb73543f286beff09da2c2d27b65
FASTAPI_USERS_VERIFICATION_TOKEN_SECRET=7246494bb622c9c89417fbe0b94de6d7718f1338eb40dd370fb072873f921832
FASTAPI_USERS_RESET_PASSWORD_TOKEN_SECRET=18ad75671edf003f0142aad124276268fa766e702ab6bdb71a75d1c71a688beb
TOKENIZERS_PARALLELISM=false
LITELLM_LOG=ERROR

View File

@@ -1,11 +0,0 @@
FROM node:22-slim
RUN npm install -g @moonshot-ai/kimi-code
WORKDIR /workspace
COPY server.js /app/server.js
EXPOSE 8012
ENTRYPOINT ["node", "/app/server.js"]

View File

@@ -1,11 +0,0 @@
FROM node:22-slim
RUN npm install -g @moonshot-ai/kimi-code
WORKDIR /workspace
COPY server.js /app/server.js
EXPOSE 8000
ENTRYPOINT ["node", "/app/server.js"]

View File

@@ -1,236 +0,0 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const PORT = 8000;
const MODEL_ID = 'kimi-agent';
const TIMEOUT_MS = 15 * 60 * 1000;
const WORKSPACE = '/workspace';
const CONV_ROOT = path.join(WORKSPACE, 'conversations');
const STATE_DIR = path.join(WORKSPACE, '.kimi-agent');
const MAP_FILE = path.join(STATE_DIR, 'sessions.json');
const MAX_ENTRIES = 1000; // prune oldest beyond this
fs.mkdirSync(CONV_ROOT, { recursive: true });
fs.mkdirSync(STATE_DIR, { recursive: true });
// --- persistent conversation -> session map ---------------------------------
// key = hash(history-so-far) -> { convId, sessionId, dir, ts }
let sessionMap = {};
try {
sessionMap = JSON.parse(fs.readFileSync(MAP_FILE, 'utf8'));
} catch {
sessionMap = {};
}
let writeQueue = Promise.resolve();
function persistMap() {
// prune to the MAX_ENTRIES most-recently-used before writing
const keys = Object.keys(sessionMap);
if (keys.length > MAX_ENTRIES) {
keys.sort((a, b) => (sessionMap[a].ts || 0) - (sessionMap[b].ts || 0));
for (const k of keys.slice(0, keys.length - MAX_ENTRIES)) delete sessionMap[k];
}
const snapshot = JSON.stringify(sessionMap);
writeQueue = writeQueue.then(
() => fs.promises.writeFile(MAP_FILE, snapshot),
() => fs.promises.writeFile(MAP_FILE, snapshot),
);
return writeQueue;
}
// --- message helpers --------------------------------------------------------
function textOf(msg) {
const c = msg.content;
if (Array.isArray(c)) return c.map(p => p.text || '').join('\n');
return c == null ? '' : String(c);
}
// only user/assistant turns define conversation identity (system is constant)
function convTurns(messages) {
return messages.filter(m => m.role === 'user' || m.role === 'assistant');
}
function historyKey(turns) {
const norm = turns.map(m => ({ role: m.role, text: textOf(m).trim() }));
return crypto.createHash('sha256').update(JSON.stringify(norm)).digest('hex');
}
function renderTranscript(turns) {
return turns
.map(m => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${textOf(m)}`)
.join('\n\n');
}
// --- kimi invocation --------------------------------------------------------
// Returns { text, sessionId }. Parses --output-format stream-json:
// {"role":"assistant","content":"..."}
// {"role":"meta","type":"session.resume_hint","session_id":"session_..."}
function runKimi({ prompt, cwd, resumeId }) {
return new Promise((resolve, reject) => {
const args = [];
if (resumeId) args.push('-r', resumeId);
args.push('-p', prompt, '--output-format', 'stream-json');
const child = spawn('kimi', args, { cwd, timeout: TIMEOUT_MS });
let stdout = '';
let stderr = '';
child.stdout.on('data', d => { stdout += d; });
child.stderr.on('data', d => { stderr += d; });
child.on('error', reject);
child.on('close', code => {
const parts = [];
let sessionId = null;
for (const line of stdout.split('\n')) {
const t = line.trim();
if (!t) continue;
let obj;
try { obj = JSON.parse(t); } catch { continue; }
if (obj.role === 'assistant' && obj.content) parts.push(obj.content);
if (obj.type === 'session.resume_hint' && obj.session_id) sessionId = obj.session_id;
}
const text = parts.join('').trim();
if (!text && code !== 0) {
reject(new Error(`kimi exited ${code}: ${stderr.slice(0, 2000)}`));
} else {
resolve({ text, sessionId });
}
});
});
}
// Decide session/dir, run kimi, and record the forward mapping.
async function handleTurn(messages) {
const turns = convTurns(messages);
// find the last user turn = the new prompt; everything before it is prior history
let lastUserIdx = -1;
for (let i = turns.length - 1; i >= 0; i--) {
if (turns[i].role === 'user') { lastUserIdx = i; break; }
}
if (lastUserIdx === -1) throw new Error('no user message found');
const newPrompt = textOf(turns[lastUserIdx]);
const prior = turns.slice(0, lastUserIdx);
let convId;
let dir;
let resumeId = null;
let prompt = newPrompt;
if (prior.length === 0) {
// brand-new conversation
convId = crypto.randomUUID();
dir = path.join(CONV_ROOT, convId);
} else {
const entry = sessionMap[historyKey(prior)];
if (entry) {
// known conversation -> resume the same kimi session in its own dir
convId = entry.convId;
dir = entry.dir;
resumeId = entry.sessionId;
} else {
// lost mapping (restart / edited history): reseed a fresh session with
// the full transcript so continuity is preserved
convId = crypto.randomUUID();
dir = path.join(CONV_ROOT, convId);
prompt = renderTranscript(turns.slice(0, lastUserIdx + 1));
}
}
fs.mkdirSync(dir, { recursive: true });
const { text, sessionId } = await runKimi({ prompt, cwd: dir, resumeId });
// store forward mapping: next request's prior history == these turns + reply
const forward = turns.slice(0, lastUserIdx + 1).concat([{ role: 'assistant', content: text }]);
sessionMap[historyKey(forward)] = {
convId,
sessionId: sessionId || resumeId,
dir,
ts: Date.now(),
};
persistMap();
return text;
}
// --- OpenAI-compatible HTTP surface ----------------------------------------
function completionBody(text) {
return {
id: `chatcmpl-${Date.now()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: MODEL_ID,
choices: [{
index: 0,
message: { role: 'assistant', content: text },
finish_reason: 'stop',
}],
};
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/v1/models') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
object: 'list',
data: [{ id: MODEL_ID, object: 'model', owned_by: 'moonshot' }],
}));
return;
}
if (req.method === 'POST' && req.url === '/v1/chat/completions') {
let body = '';
req.on('data', d => { body += d; });
req.on('end', async () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'invalid JSON body' }));
return;
}
try {
const text = await handleTurn(parsed.messages || []);
if (parsed.stream) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const id = `chatcmpl-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
res.write(`data: ${JSON.stringify({
id, object: 'chat.completion.chunk', created, model: MODEL_ID,
choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: null }],
})}\n\n`);
res.write(`data: ${JSON.stringify({
id, object: 'chat.completion.chunk', created, model: MODEL_ID,
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
})}\n\n`);
res.write('data: [DONE]\n\n');
res.end();
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(completionBody(text)));
}
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(err.message || err) }));
}
});
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
server.listen(PORT, () => console.log(`kimi-agent wrapper listening on :${PORT}`));