Memory migration (H1-H5, kb#73-77,84): - hindsight service in openai/docker-compose.yml: LLM via Kimi (cognee-llm wrapper), multilingual GPU embeddings (bge-m3 via ollama), jina multilingual reranker; pg0 + model cache persisted - openclaw.json/shared-mcp.json: mcp.servers cognee -> hindsight (bank "adolf") - hindsight-openclaw-plugin: forced-hook memory (before_prompt_build recall + agent_end retain), replacing cognee's hook layer; cognify-sweep dropped - verified live: Russian retain->recall, cross-session recall, bank isolation Kimi quota (kb#62): - adolf-llm/server.js: LLM-free GET /usage route (Kimi managed-usage API) - quota-command-openclaw-plugin: /quota readout command Cognee stack left running (decommission is H4/kb#76). Kimi-quota-footer auto-append abandoned (streamed Matrix replies bypass outbound hooks). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014t8Qg9gi7H7HtT8MncoXAB
763 lines
29 KiB
JavaScript
763 lines
29 KiB
JavaScript
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { spawn } = require('child_process');
|
|
|
|
const PORT = 8010;
|
|
const MODEL_ID = 'adolf';
|
|
const TIMEOUT_MS = 15 * 60 * 1000;
|
|
|
|
const 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');
|
|
const MAX_ENTRIES = 1000; // prune oldest beyond this
|
|
|
|
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.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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`.
|
|
let SHARED_MCP_SERVERS = {};
|
|
try {
|
|
const raw = fs.readFileSync('/shared-mcp.json', '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));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Memory lives at the OpenClaw layer, not here (P8). The Adolf gateway loads
|
|
// the `cognee-memory` OpenClaw plugin, which owns all memory touchpoints:
|
|
// - before_prompt_build => LLM-free cognee graph recall, injected into the
|
|
// prompt this wrapper then receives from OpenClaw.
|
|
// - agent_end => raw `add` of the turn to cognee.
|
|
// - background sweep => async `cognify` on cognee-llm (Kimi).
|
|
// - cognee_recall tool + cognee-mcp `recall` for on-demand deep queries.
|
|
// This wrapper is therefore a dumb model endpoint again: it must never call
|
|
// cognee itself. The former cogneeSearch/cogneeAdd stubs (and their call sites)
|
|
// were deleted when the plugin took over (P8).
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Persistent conversation -> Kimi session 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).
|
|
// value = { 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 => (typeof p === 'string' ? 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');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gate 2 — parse the stable chat_id out of OpenClaw's untrusted-metadata block.
|
|
// OpenClaw injects, into the *user-role* content, a block that looks like:
|
|
// Conversation info (untrusted metadata):
|
|
// ```json
|
|
// { "chat_id": "matrix:!room:server", "message_id": "...", ... }
|
|
// ```
|
|
// We grep on the label string (never a fixed line offset) then pull the first
|
|
// balanced JSON object after it and read chat_id. Robust to edited/truncated
|
|
// history, which is exactly why it beats a history hash for the common case.
|
|
const CONV_INFO_LABEL = 'Conversation info (untrusted metadata):';
|
|
|
|
function extractBalancedJson(str, from) {
|
|
const start = str.indexOf('{', from);
|
|
if (start === -1) return null;
|
|
let depth = 0;
|
|
let inStr = false;
|
|
let esc = false;
|
|
for (let i = start; i < str.length; i++) {
|
|
const ch = str[i];
|
|
if (inStr) {
|
|
if (esc) esc = false;
|
|
else if (ch === '\\') esc = true;
|
|
else if (ch === '"') inStr = false;
|
|
continue;
|
|
}
|
|
if (ch === '"') inStr = true;
|
|
else if (ch === '{') depth++;
|
|
else if (ch === '}') {
|
|
depth--;
|
|
if (depth === 0) return str.slice(start, i + 1);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractChatId(userMsg) {
|
|
const text = textOf(userMsg);
|
|
const at = text.indexOf(CONV_INFO_LABEL);
|
|
if (at === -1) return null;
|
|
const jsonStr = extractBalancedJson(text, at + CONV_INFO_LABEL.length);
|
|
if (!jsonStr) return null;
|
|
try {
|
|
const obj = JSON.parse(jsonStr);
|
|
const id = obj.chat_id;
|
|
return typeof id === 'string' && id ? id : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gate 3 — media. Persist inbound image parts into the session dir and return
|
|
// relative path references; the CLI autonomously calls its built-in
|
|
// ReadMediaFile tool on referenced paths (no flag/placeholder syntax needed).
|
|
const MIME_EXT = {
|
|
'image/png': 'png',
|
|
'image/jpeg': 'jpg',
|
|
'image/jpg': 'jpg',
|
|
'image/webp': 'webp',
|
|
'image/gif': 'gif',
|
|
'image/bmp': 'bmp',
|
|
'image/heic': 'heic',
|
|
'image/heif': 'heif',
|
|
};
|
|
|
|
function extFromMime(mime) {
|
|
return MIME_EXT[(mime || '').toLowerCase()] || 'img';
|
|
}
|
|
|
|
// Persist one image_url part; returns "./img_N.ext" or null if it couldn't.
|
|
async function persistImage(url, dir, n) {
|
|
if (typeof url !== 'string' || !url) return null;
|
|
if (url.startsWith('data:')) {
|
|
const m = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(url);
|
|
if (!m) return null;
|
|
const mime = m[1] || 'application/octet-stream';
|
|
const isB64 = !!m[2];
|
|
const ext = extFromMime(mime);
|
|
const name = `img_${n}.${ext}`;
|
|
const buf = isB64
|
|
? Buffer.from(m[3], 'base64')
|
|
: Buffer.from(decodeURIComponent(m[3]), 'utf8');
|
|
fs.writeFileSync(path.join(dir, name), buf);
|
|
return `./${name}`;
|
|
}
|
|
// Remote URL: best-effort fetch so the CLI gets a local path to ReadMediaFile.
|
|
// On any failure fall back to handing the raw URL to the model as text.
|
|
if (/^https?:\/\//i.test(url)) {
|
|
try {
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) return url;
|
|
const mime = resp.headers.get('content-type') || '';
|
|
const ext = extFromMime(mime.split(';')[0].trim());
|
|
const name = `img_${n}.${ext}`;
|
|
const buf = Buffer.from(await resp.arrayBuffer());
|
|
fs.writeFileSync(path.join(dir, name), buf);
|
|
return `./${name}`;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Build the prompt for the current user turn: join text parts, persist any
|
|
// image parts, append path references.
|
|
async function buildPrompt(userMsg, dir) {
|
|
const content = userMsg.content;
|
|
const textParts = [];
|
|
const imageRefs = [];
|
|
|
|
if (Array.isArray(content)) {
|
|
let n = 0;
|
|
for (const part of content) {
|
|
if (typeof part === 'string') {
|
|
textParts.push(part);
|
|
} else if (part && (part.type === 'text' || typeof part.text === 'string')) {
|
|
textParts.push(part.text || '');
|
|
} else if (part && part.type === 'image_url' && part.image_url && part.image_url.url) {
|
|
n++;
|
|
const ref = await persistImage(part.image_url.url, dir, n);
|
|
if (ref) imageRefs.push(ref);
|
|
} else if (part && part.type === 'input_image' && (part.image_url || part.url)) {
|
|
n++;
|
|
const u = typeof part.image_url === 'string' ? part.image_url : part.url;
|
|
const ref = await persistImage(u, dir, n);
|
|
if (ref) imageRefs.push(ref);
|
|
}
|
|
}
|
|
} else {
|
|
textParts.push(content == null ? '' : String(content));
|
|
}
|
|
|
|
let prompt = textParts.join('\n');
|
|
if (imageRefs.length) {
|
|
prompt += '\n\n' + imageRefs.map(r => `See attached image: ${r}`).join('\n');
|
|
}
|
|
return prompt;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
// 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 }) {
|
|
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');
|
|
|
|
const child = spawn('kimi', args, { cwd, timeout: TIMEOUT_MS });
|
|
|
|
let buf = '';
|
|
let stderr = '';
|
|
const parts = [];
|
|
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
|
|
// a dead socket. SIGTERM first, hard SIGKILL if it lingers.
|
|
const onAbort = () => {
|
|
aborted = true;
|
|
try { child.kill('SIGTERM'); } catch {}
|
|
setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 3000).unref();
|
|
};
|
|
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
|
|
|
function handleLine(line) {
|
|
const t = line.trim();
|
|
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);
|
|
}
|
|
if (obj.type === 'session.resume_hint' && obj.session_id) sessionId = obj.session_id;
|
|
}
|
|
|
|
child.stdout.on('data', d => {
|
|
buf += d;
|
|
let nl;
|
|
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
const line = buf.slice(0, nl);
|
|
buf = buf.slice(nl + 1);
|
|
handleLine(line);
|
|
}
|
|
});
|
|
child.stderr.on('data', d => { stderr += d; });
|
|
|
|
child.on('error', err => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (signal) signal.removeEventListener('abort', onAbort);
|
|
reject(err);
|
|
});
|
|
child.on('close', code => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (signal) signal.removeEventListener('abort', onAbort);
|
|
if (buf) handleLine(buf); // flush any trailing partial line
|
|
const text = parts.join('').trim();
|
|
if (aborted) {
|
|
reject(new Error('aborted: client disconnected'));
|
|
} else if (!text && code !== 0) {
|
|
reject(new Error(`kimi exited ${code}: ${stderr.slice(0, 2000)}`));
|
|
} else {
|
|
resolve({ text, sessionId });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// One turn: resolve session (chat_id primary, history-hash fallback), persist
|
|
// media + .mcp.json, run kimi (streaming through onDelta), record the mapping,
|
|
// and fire the async cognee ingest. Returns { text }.
|
|
async function handleTurn(messages, onDelta, signal) {
|
|
const turns = convTurns(messages);
|
|
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 userMsg = turns[lastUserIdx];
|
|
const prior = turns.slice(0, lastUserIdx);
|
|
const chatId = extractChatId(userMsg);
|
|
|
|
// Resolve the session key + working dir + resume id.
|
|
let key;
|
|
let convId;
|
|
let dir;
|
|
let resumeId = null;
|
|
let reseed = false; // when true, prompt with the full transcript to rebuild continuity
|
|
|
|
if (chatId) {
|
|
key = `chat:${chatId}`;
|
|
const entry = sessionMap[key];
|
|
if (entry) {
|
|
convId = entry.convId;
|
|
dir = entry.dir;
|
|
resumeId = entry.sessionId;
|
|
} else {
|
|
convId = crypto.randomUUID();
|
|
dir = path.join(CONV_ROOT, convId);
|
|
// First time we see this chat_id but history exists (server restart / lost
|
|
// map): reseed the fresh session with the transcript so context survives.
|
|
reseed = prior.length > 0;
|
|
}
|
|
} else {
|
|
// Fallback: no chat_id -> forward history-hash mapping (kimi-agent style).
|
|
if (prior.length === 0) {
|
|
convId = crypto.randomUUID();
|
|
dir = path.join(CONV_ROOT, convId);
|
|
} else {
|
|
const entry = sessionMap[`hist:${historyKey(prior)}`];
|
|
if (entry) {
|
|
convId = entry.convId;
|
|
dir = entry.dir;
|
|
resumeId = entry.sessionId;
|
|
} else {
|
|
convId = crypto.randomUUID();
|
|
dir = path.join(CONV_ROOT, convId);
|
|
reseed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
writeMcpConfig(dir); // Gate 1: shared MCP via project-root .mcp.json
|
|
|
|
let prompt;
|
|
if (reseed) {
|
|
// Rebuild the whole conversation for a fresh session, plus current media.
|
|
const base = renderTranscript(turns.slice(0, lastUserIdx + 1));
|
|
const media = await buildPrompt(userMsg, dir);
|
|
// buildPrompt already includes the current user text; for reseed we want the
|
|
// transcript to carry it, so only append image refs.
|
|
prompt = base;
|
|
const extra = media.replace(textOf(userMsg), '').trim();
|
|
if (extra) prompt += `\n\n${extra}`;
|
|
} else {
|
|
prompt = await buildPrompt(userMsg, dir);
|
|
}
|
|
|
|
const { text, sessionId } = await runKimi({ prompt, cwd: dir, resumeId, onDelta, signal });
|
|
|
|
// Record the forward mapping.
|
|
const entry = { convId, sessionId: sessionId || resumeId, dir, ts: Date.now() };
|
|
if (chatId) {
|
|
sessionMap[`chat:${chatId}`] = entry;
|
|
} else {
|
|
const forward = turns.slice(0, lastUserIdx + 1).concat([{ role: 'assistant', content: text }]);
|
|
sessionMap[`hist:${historyKey(forward)}`] = entry;
|
|
}
|
|
persistMap();
|
|
|
|
return { text };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
//
|
|
// Token source: the CLI's own OAuth creds file, kept fresh by the running
|
|
// `kimi` process (adolf-llm-home volume). We only ever READ that file. If
|
|
// its access_token is stale/expired we refresh in memory (POST
|
|
// https://auth.kimi.com/api/oauth/token, form-encoded, grant_type=
|
|
// refresh_token — endpoint + client_id taken from the same decompiled
|
|
// KIMI_CODE_FLOW_CONFIG/refreshAccessToken) and cache the result in a
|
|
// module-level variable ONLY — we deliberately never write the refreshed
|
|
// token back to the creds file, since the live CLI owns that file and a
|
|
// racing write from here could corrupt/rotate state it depends on.
|
|
const KIMI_CREDS_PATH = '/root/.kimi-code/credentials/kimi-code.json';
|
|
const KIMI_OAUTH_HOST = 'https://auth.kimi.com';
|
|
const KIMI_CLIENT_ID = '17e5f671-d194-4dfb-9706-5516cb48c098';
|
|
const KIMI_USAGES_URL = 'https://api.kimi.com/coding/v1/usages';
|
|
|
|
let kimiMemToken = null; // { access_token, expires_at } — in-memory only, never persisted
|
|
|
|
async function loadKimiCreds() {
|
|
const raw = await fs.promises.readFile(KIMI_CREDS_PATH, 'utf8');
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
async function refreshKimiToken(refreshToken) {
|
|
const body = new URLSearchParams({
|
|
client_id: KIMI_CLIENT_ID,
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
}).toString();
|
|
const res = await fetch(`${KIMI_OAUTH_HOST}/api/oauth/token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
body,
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok || typeof data.access_token !== 'string') {
|
|
throw new Error(`kimi oauth refresh failed (HTTP ${res.status}): ${data.error || data.error_description || 'unknown error'}`);
|
|
}
|
|
return {
|
|
access_token: data.access_token,
|
|
expires_at: Math.floor(Date.now() / 1000) + Number(data.expires_in || 900),
|
|
};
|
|
}
|
|
|
|
// Resolve a usable access token, preferring the creds-file token (kept fresh
|
|
// by the live CLI) and falling back to an in-memory refresh only when that
|
|
// one is stale/expired.
|
|
async function getKimiAccessToken(forceRefresh) {
|
|
const creds = await loadKimiCreds();
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (!forceRefresh && creds.access_token && creds.expires_at && now < creds.expires_at - 30) {
|
|
return creds.access_token;
|
|
}
|
|
if (!forceRefresh && kimiMemToken && now < kimiMemToken.expires_at - 30) {
|
|
return kimiMemToken.access_token;
|
|
}
|
|
if (!creds.refresh_token) throw new Error('no refresh_token in kimi credentials file');
|
|
kimiMemToken = await refreshKimiToken(creds.refresh_token);
|
|
return kimiMemToken.access_token;
|
|
}
|
|
|
|
async function fetchKimiUsagesRaw() {
|
|
let token = await getKimiAccessToken(false);
|
|
let res = await fetch(KIMI_USAGES_URL, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } });
|
|
if (res.status === 401) {
|
|
token = await getKimiAccessToken(true); // force one in-memory refresh + retry
|
|
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) })),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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',
|
|
}],
|
|
};
|
|
}
|
|
|
|
function sseChunk(id, created, delta, finishReason) {
|
|
return `data: ${JSON.stringify({
|
|
id, object: 'chat.completion.chunk', created, model: MODEL_ID,
|
|
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
})}\n\n`;
|
|
}
|
|
|
|
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 === 'GET' && req.url === '/usage') {
|
|
(async () => {
|
|
try {
|
|
const raw = await fetchKimiUsagesRaw();
|
|
const out = normalizeKimiUsage(raw);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(out));
|
|
} catch (err) {
|
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: String(err.message || err) }));
|
|
}
|
|
})();
|
|
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;
|
|
}
|
|
|
|
const messages = parsed.messages || [];
|
|
|
|
if (parsed.stream) {
|
|
// Real streaming: open SSE, emit role chunk, then forward kimi deltas.
|
|
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);
|
|
|
|
// 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
|
|
// 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 —
|
|
// still a stream event (resets the watchdog) but appends nothing
|
|
// visible to the rendered reply or cognee-persisted text.
|
|
let lastWrite = Date.now();
|
|
const write = (delta, finish) => {
|
|
if (res.writableEnded || res.destroyed) return;
|
|
res.write(sseChunk(id, created, delta, finish));
|
|
lastWrite = Date.now();
|
|
};
|
|
write({ role: 'assistant' }, null);
|
|
const hb = setInterval(() => {
|
|
if (Date.now() - lastWrite >= 25_000) write({ content: '' }, null);
|
|
}, 5_000);
|
|
|
|
// Propagate a client/gateway disconnect down to the kimi 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.
|
|
const ac = new AbortController();
|
|
let done = false;
|
|
res.on('close', () => { if (!done) ac.abort(); });
|
|
|
|
try {
|
|
await handleTurn(messages, delta => write({ content: delta }, null), ac.signal);
|
|
done = true;
|
|
write({}, 'stop');
|
|
res.write('data: [DONE]\n\n');
|
|
} catch (err) {
|
|
done = true;
|
|
// Headers already sent — surface the error inside the stream (unless
|
|
// the socket is already gone, in which case there is nowhere to write).
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
write({ content: `\n[error: ${String(err.message || err)}]` }, 'stop');
|
|
res.write('data: [DONE]\n\n');
|
|
}
|
|
} finally {
|
|
clearInterval(hb);
|
|
if (!res.writableEnded) res.end();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const ac = new AbortController();
|
|
let done = false;
|
|
res.on('close', () => { if (!done) ac.abort(); });
|
|
try {
|
|
const { text } = await handleTurn(messages, null, ac.signal);
|
|
done = true;
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(completionBody(text)));
|
|
} catch (err) {
|
|
done = true;
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
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(`adolf-llm wrapper listening on :${PORT}`));
|