Files
memind/.runtime/portal/scripts/agent-run-worker.mjs
T
2026-07-03 00:34:03 +08:00

9404 lines
380 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);
// scripts/agent-run-worker.mjs
import fs9 from "node:fs";
import path12 from "node:path";
import { fileURLToPath as fileURLToPath5 } from "node:url";
// agent-run-gateway.mjs
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
var DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5e3, 15e3];
var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["succeeded", "failed"]);
var CODE_TOOL_MODES = /* @__PURE__ */ new Set(["code", "code-task", "code_task", "code-tool", "code_tool", "code_tool_task"]);
var RUN_METADATA_KEY = "memindRun";
var DEFAULT_MAX_CONCURRENT_RUNS = 1;
var DEFAULT_RUN_TIMEOUT_MS = 15 * 60 * 1e3;
var DEFAULT_RUN_HEARTBEAT_MS = 30 * 1e3;
var TOOL_GATEWAY_SUMMARY_LIMIT = 4096;
function nowMs() {
return Date.now();
}
function safeJsonParse(value, fallback = null) {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function serializeMessage(message) {
return JSON.stringify(message ?? {});
}
function positiveInteger(value, fallback) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.floor(n);
}
function timeoutError(ms) {
const err = new Error(`agent run timed out after ${ms}ms`);
err.code = "AGENT_RUN_TIMEOUT";
return err;
}
function envFlag(value, fallback = false) {
const raw = String(value ?? "").trim().toLowerCase();
if (!raw) return fallback;
return ["1", "true", "yes", "on"].includes(raw);
}
function normalizeAgentRunToolMode(value) {
const normalized = String(value ?? "chat").trim().toLowerCase();
if (!normalized || normalized === "chat") return "chat";
if (CODE_TOOL_MODES.has(normalized)) return "code";
throw new Error(`\u4E0D\u652F\u6301\u7684 tool_mode: ${value}`);
}
function normalizeTaskType(value) {
const normalized = String(value ?? "").trim();
return normalized || null;
}
function summarizeText(value, limit = TOOL_GATEWAY_SUMMARY_LIMIT) {
const text = String(value ?? "");
if (text.length <= limit) return text;
return text.slice(text.length - limit);
}
function normalizeExpectedFileCheck(value) {
if (typeof value === "string") {
const expectedPath2 = value.trim();
return expectedPath2 ? { path: expectedPath2 } : null;
}
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const expectedPath = String(value.path ?? value.file ?? value.relativePath ?? "").trim();
if (!expectedPath) return null;
const contains = value.contains ?? value.expectedContent ?? value.contentIncludes;
return {
path: expectedPath,
...contains == null ? {} : { contains: String(contains) }
};
}
function normalizeToolGatewayValidation(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const checks = [];
const single = normalizeExpectedFileCheck(value.expectedFile ?? value.expectedPath);
if (single) checks.push(single);
if (Array.isArray(value.expectedFiles)) {
for (const item of value.expectedFiles) {
const check = normalizeExpectedFileCheck(item);
if (check) checks.push(check);
}
}
return checks.length > 0 ? { expectedFiles: checks } : null;
}
function resolveValidationPath(cwd, expectedPath) {
if (!String(cwd ?? "").trim()) {
const err = new Error("Tool Gateway validation failed: missing working directory");
err.code = "TOOL_GATEWAY_VALIDATION_FAILED";
err.retryable = false;
throw err;
}
const root2 = path.resolve(String(cwd ?? ""));
const target = path.resolve(root2, String(expectedPath ?? ""));
if (target !== root2 && !target.startsWith(`${root2}${path.sep}`)) {
const err = new Error(`Tool Gateway validation path escapes working directory: ${expectedPath}`);
err.code = "TOOL_GATEWAY_VALIDATION_FAILED";
err.retryable = false;
throw err;
}
return { root: root2, target };
}
async function validateToolGatewayResult({ result, validation, cwd }) {
if (!validation?.expectedFiles?.length || result?.dryRun) {
return null;
}
const checks = [];
for (const expected of validation.expectedFiles) {
const { root: root2, target } = resolveValidationPath(cwd ?? result?.cwd, expected.path);
let content = "";
let stat = null;
try {
stat = await fs.stat(target);
content = await fs.readFile(target, "utf8");
} catch (cause) {
const err = new Error(`Tool Gateway validation failed: expected file not found: ${expected.path}`);
err.code = "TOOL_GATEWAY_VALIDATION_FAILED";
err.retryable = false;
err.validation = {
expectedFile: expected.path,
cwd: root2,
reason: "missing_file"
};
err.cause = cause;
throw err;
}
if (expected.contains != null && !content.includes(expected.contains)) {
const err = new Error(`Tool Gateway validation failed: expected content not found in ${expected.path}`);
err.code = "TOOL_GATEWAY_VALIDATION_FAILED";
err.retryable = false;
err.validation = {
expectedFile: expected.path,
cwd: root2,
reason: "missing_content"
};
throw err;
}
checks.push({
path: expected.path,
sizeBytes: Number(stat?.size ?? 0),
contains: expected.contains == null ? null : true
});
}
return { expectedFiles: checks };
}
function withRunMetadata(userMessage, { toolMode = "chat", taskType = null } = {}) {
const message = userMessage && typeof userMessage === "object" && !Array.isArray(userMessage) ? { ...userMessage } : { value: userMessage };
const metadata = message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata) ? { ...message.metadata } : {};
metadata[RUN_METADATA_KEY] = {
...metadata[RUN_METADATA_KEY] && typeof metadata[RUN_METADATA_KEY] === "object" ? metadata[RUN_METADATA_KEY] : {},
toolMode: normalizeAgentRunToolMode(toolMode),
...taskType ? { taskType } : {}
};
return { ...message, metadata };
}
function getRunOptionsFromMessage(userMessage) {
const metadata = userMessage?.metadata;
const runMetadata = metadata?.[RUN_METADATA_KEY] ?? metadata?.agentRun ?? {};
let toolMode = "chat";
try {
toolMode = normalizeAgentRunToolMode(runMetadata?.toolMode ?? metadata?.toolMode ?? "chat");
} catch {
toolMode = "chat";
}
return {
toolMode,
taskType: normalizeTaskType(runMetadata?.taskType ?? metadata?.taskType),
validation: normalizeToolGatewayValidation(runMetadata?.validation ?? metadata?.toolGatewayValidation)
};
}
function projectRun(row) {
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
sessionId: row.agent_session_id ?? null,
requestId: row.request_id,
status: row.status,
attempts: Number(row.attempts ?? 0),
error: row.error_message ?? null,
createdAt: Number(row.created_at ?? 0),
updatedAt: Number(row.updated_at ?? 0),
startedAt: row.started_at == null ? null : Number(row.started_at),
completedAt: row.completed_at == null ? null : Number(row.completed_at)
};
}
function createAgentRunGateway({
pool: pool2,
userAuth: userAuth2,
tkmindProxy: tkmindProxy2,
toolGateway: toolGateway2 = null,
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true),
maxConcurrentRuns = positiveInteger(
process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY,
DEFAULT_MAX_CONCURRENT_RUNS
),
runTimeoutMs = positiveInteger(
process.env.MEMIND_AGENT_RUN_TIMEOUT_MS,
DEFAULT_RUN_TIMEOUT_MS
),
heartbeatMs = positiveInteger(
process.env.MEMIND_AGENT_RUN_HEARTBEAT_MS,
DEFAULT_RUN_HEARTBEAT_MS
)
}) {
const inFlight = /* @__PURE__ */ new Set();
const queuedDispatches = [];
const queuedDispatchSet = /* @__PURE__ */ new Set();
function enqueueRun(runId) {
if (!runId || inFlight.has(runId) || queuedDispatchSet.has(runId)) return false;
queuedDispatchSet.add(runId);
queuedDispatches.push(runId);
return true;
}
function nextQueuedRunId() {
const runId = queuedDispatches.shift();
if (runId) queuedDispatchSet.delete(runId);
return runId ?? null;
}
async function appendEvent(runId, type, data = null) {
await pool2.query(
`INSERT INTO h5_agent_run_events (id, run_id, event_type, data_json, created_at)
VALUES (?, ?, ?, ?, ?)`,
[
crypto.randomUUID(),
runId,
type,
data == null ? null : JSON.stringify(data),
nowMs()
]
);
}
async function getRunById(runId) {
const [rows] = await pool2.query(
`SELECT * FROM h5_agent_runs WHERE id = ? LIMIT 1`,
[runId]
);
return rows[0] ?? null;
}
async function getRunForUser(userId, runId) {
const [rows] = await pool2.query(
`SELECT * FROM h5_agent_runs WHERE id = ? AND user_id = ? LIMIT 1`,
[runId, userId]
);
return projectRun(rows[0] ?? null);
}
async function getRunByRequest(userId, requestId) {
const [rows] = await pool2.query(
`SELECT * FROM h5_agent_runs WHERE user_id = ? AND request_id = ? LIMIT 1`,
[userId, requestId]
);
return rows[0] ?? null;
}
async function createRun(userId, {
sessionId = null,
requestId,
userMessage,
toolMode = "chat",
taskType = null
}) {
const normalizedRequestId = String(requestId ?? "").trim();
if (!normalizedRequestId) {
throw new Error("\u7F3A\u5C11 request_id");
}
const normalizedToolMode = normalizeAgentRunToolMode(toolMode);
const normalizedTaskType = normalizeTaskType(taskType);
const existing = await getRunByRequest(userId, normalizedRequestId);
if (existing) {
if (autoDispatch && !TERMINAL_STATUSES.has(existing.status)) dispatchRun(existing.id);
return projectRun(existing);
}
const runId = crypto.randomUUID();
const createdAt = nowMs();
const runMessage = withRunMetadata(userMessage, {
toolMode: normalizedToolMode,
taskType: normalizedTaskType
});
await pool2.query(
`INSERT INTO h5_agent_runs
(id, user_id, agent_session_id, request_id, status, attempts,
user_message_json, error_message, created_at, updated_at, started_at, completed_at)
VALUES (?, ?, ?, ?, 'queued', 0, ?, NULL, ?, ?, NULL, NULL)`,
[
runId,
userId,
sessionId || null,
normalizedRequestId,
serializeMessage(runMessage),
createdAt,
createdAt
]
);
await appendEvent(runId, "queued", {
sessionId: sessionId || null,
toolMode: normalizedToolMode,
taskType: normalizedTaskType
});
if (autoDispatch) dispatchRun(runId);
return projectRun(await getRunById(runId));
}
async function markRun(runId, status, fields = {}) {
const updates = ["status = ?", "updated_at = ?"];
const values = [status, nowMs()];
for (const [key, value] of Object.entries(fields)) {
updates.push(`${key} = ?`);
values.push(value);
}
values.push(runId);
await pool2.query(
`UPDATE h5_agent_runs SET ${updates.join(", ")} WHERE id = ?`,
values
);
await appendEvent(runId, status, fields);
}
function startRunHeartbeat(runId, { attempt }) {
let stopped = false;
const writeHeartbeat = async () => {
if (stopped) return;
try {
await appendEvent(runId, "worker_heartbeat", {
attempt,
pid: process.pid,
heartbeatMs
});
} catch (err) {
console.error("[AgentRun] worker heartbeat failed:", err instanceof Error ? err.message : err);
}
};
void writeHeartbeat();
const timer = setInterval(() => {
void writeHeartbeat();
}, heartbeatMs);
timer.unref?.();
return () => {
stopped = true;
clearInterval(timer);
};
}
async function runWithTimeout(runId, task) {
let timer = null;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(timeoutError(runTimeoutMs)), runTimeoutMs);
});
try {
return await Promise.race([task(), timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function executeRun(row, runId) {
const userMessage = safeJsonParse(row.user_message_json, {});
const runOptions = getRunOptionsFromMessage(userMessage);
const toolGatewayStatus = toolGateway2?.getStatus ? toolGateway2.getStatus() : null;
if (runOptions.toolMode === "code" && toolGatewayStatus?.enabled) {
const workingDir = userAuth2?.resolveWorkingDir ? await userAuth2.resolveWorkingDir(row.user_id) : void 0;
await appendEvent(runId, "tool_gateway_dispatch", {
protocol: toolGatewayStatus.protocol ?? "agent-run-v1",
taskType: runOptions.taskType,
workingDir: workingDir ?? null
});
const result = await toolGateway2.executeJob({
runId,
userId: row.user_id,
requestId: row.request_id,
userMessage,
taskType: runOptions.taskType,
cwd: workingDir,
timeoutMs: runTimeoutMs
});
await appendEvent(runId, "tool_gateway_result", {
executor: result.executor ?? null,
dryRun: Boolean(result.dryRun),
exitCode: result.exitCode ?? null,
stdoutTail: summarizeText(result.stdout),
stderrTail: summarizeText(result.stderr)
});
try {
const validation = await validateToolGatewayResult({
result,
validation: runOptions.validation,
cwd: workingDir
});
if (validation) {
await appendEvent(runId, "tool_gateway_validation", validation);
}
} catch (err) {
await appendEvent(runId, "tool_gateway_validation_failed", {
code: err?.code ?? null,
message: err instanceof Error ? err.message : String(err),
validation: err?.validation ?? null
});
throw err;
}
return { sessionId: row.agent_session_id ?? null };
}
let sessionId = row.agent_session_id ?? null;
if (!sessionId) {
const sessionOptions = {};
if (runOptions.toolMode === "code" && userAuth2?.getCodeAgentSessionPolicy) {
sessionOptions.sessionPolicy = await userAuth2.getCodeAgentSessionPolicy(row.user_id);
}
const session = await tkmindProxy2.startSessionForUser(row.user_id, sessionOptions);
sessionId = session.id;
await pool2.query(
`UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`,
[sessionId, nowMs(), runId]
);
await appendEvent(runId, "session_started", {
sessionId,
toolMode: runOptions.toolMode,
taskType: runOptions.taskType
});
}
await tkmindProxy2.submitSessionReplyForUser(
row.user_id,
sessionId,
row.request_id,
userMessage,
{ toolMode: runOptions.toolMode }
);
return { sessionId };
}
async function processRun(runId) {
const row = await getRunById(runId);
if (!row || TERMINAL_STATUSES.has(row.status)) return;
const nextAttempt = Number(row.attempts ?? 0) + 1;
const [claim] = await pool2.query(
`UPDATE h5_agent_runs
SET status = 'running', attempts = ?, started_at = COALESCE(started_at, ?), updated_at = ?, error_message = NULL
WHERE id = ? AND status IN ('queued', 'retryable')`,
[nextAttempt, nowMs(), nowMs(), runId]
);
if (Number(claim?.affectedRows ?? 0) === 0) return;
await appendEvent(runId, "running", { attempt: nextAttempt });
const stopHeartbeat = startRunHeartbeat(runId, { attempt: nextAttempt });
try {
const { sessionId } = await runWithTimeout(runId, () => executeRun(row, runId));
await markRun(runId, "succeeded", {
agent_session_id: sessionId,
completed_at: nowMs()
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const timedOut = err?.code === "AGENT_RUN_TIMEOUT";
if (timedOut) {
await appendEvent(runId, "timeout", { timeoutMs: runTimeoutMs });
}
const retryable = !timedOut && err?.retryable !== false && nextAttempt < retryDelaysMs.length;
await markRun(runId, retryable ? "retryable" : "failed", {
error_message: message,
completed_at: retryable ? null : nowMs()
});
if (retryable && autoDispatch) {
setTimeout(() => dispatchRun(runId), retryDelaysMs[nextAttempt - 1]);
}
} finally {
stopHeartbeat();
}
}
function drainQueue() {
while (inFlight.size < maxConcurrentRuns) {
const runId = nextQueuedRunId();
if (!runId) break;
inFlight.add(runId);
void processRun(runId).finally(() => {
inFlight.delete(runId);
drainQueue();
});
}
}
function dispatchRun(runId) {
if (!enqueueRun(runId)) return;
drainQueue();
}
async function getQueueStatus() {
const [rows] = await pool2.query(
`SELECT status, COUNT(*) AS count
FROM h5_agent_runs
WHERE status IN ('queued', 'running', 'retryable')
GROUP BY status`
);
const statusCounts = {};
for (const row of rows) {
statusCounts[row.status] = Number(row.count ?? 0);
}
const [oldestRunningRows] = await pool2.query(
`SELECT
r.id,
r.request_id,
r.started_at,
r.updated_at,
r.attempts,
h.latest_heartbeat_at
FROM h5_agent_runs r
LEFT JOIN (
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
FROM h5_agent_run_events
WHERE event_type = 'worker_heartbeat'
GROUP BY run_id
) h ON h.run_id = r.id
WHERE r.status = 'running'
ORDER BY COALESCE(h.latest_heartbeat_at, r.started_at) ASC
LIMIT 1`
);
const oldestRunningStartedAt = oldestRunningRows[0]?.started_at == null ? null : Number(oldestRunningRows[0].started_at);
const oldestRunningAgeMs = oldestRunningStartedAt == null ? 0 : Math.max(0, nowMs() - oldestRunningStartedAt);
const oldestRunningHeartbeatAt = oldestRunningRows[0]?.latest_heartbeat_at == null ? null : Number(oldestRunningRows[0].latest_heartbeat_at);
const oldestRunningHeartbeatAgeMs = oldestRunningRows[0] ? Math.max(0, nowMs() - Number(oldestRunningRows[0].latest_heartbeat_at ?? oldestRunningRows[0].started_at ?? nowMs())) : 0;
const [runningWithoutHeartbeatRows] = await pool2.query(
`SELECT COUNT(*) AS count
FROM h5_agent_runs r
LEFT JOIN (
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
FROM h5_agent_run_events
WHERE event_type = 'worker_heartbeat'
GROUP BY run_id
) h ON h.run_id = r.id
WHERE r.status = 'running' AND h.latest_heartbeat_at IS NULL`
);
return {
autoDispatch,
maxConcurrentRuns,
runTimeoutMs,
heartbeatMs,
inFlight: inFlight.size,
pendingDispatches: queuedDispatches.length,
statusCounts,
oldestRunningStartedAt,
oldestRunningAgeMs,
oldestRunningHeartbeatAt,
oldestRunningHeartbeatAgeMs,
runningWithoutHeartbeatCount: Number(runningWithoutHeartbeatRows[0]?.count ?? 0),
latestRunningRun: oldestRunningRows[0] ? {
id: oldestRunningRows[0].id,
requestId: oldestRunningRows[0].request_id,
startedAt: oldestRunningStartedAt,
updatedAt: oldestRunningRows[0].updated_at == null ? null : Number(oldestRunningRows[0].updated_at),
attempts: Number(oldestRunningRows[0].attempts ?? 0),
heartbeatAt: oldestRunningHeartbeatAt,
heartbeatAgeMs: oldestRunningHeartbeatAgeMs
} : null,
terminalStatuses: [...TERMINAL_STATUSES],
toolGateway: toolGateway2?.getStatus ? toolGateway2.getStatus() : null
};
}
async function recoverStaleRunningRuns({
staleMs = runTimeoutMs,
limit = maxConcurrentRuns,
dryRun = true,
reason = "stale_running_timeout"
} = {}) {
const normalizedStaleMs = positiveInteger(staleMs, runTimeoutMs);
const normalizedLimit = positiveInteger(limit, maxConcurrentRuns);
const cutoff = nowMs() - normalizedStaleMs;
const [rows] = await pool2.query(
`SELECT
r.id,
r.request_id,
r.started_at,
r.updated_at,
r.attempts,
h.latest_heartbeat_at
FROM h5_agent_runs r
LEFT JOIN (
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
FROM h5_agent_run_events
WHERE event_type = 'worker_heartbeat'
GROUP BY run_id
) h ON h.run_id = r.id
WHERE r.status = 'running'
AND r.started_at IS NOT NULL
AND COALESCE(h.latest_heartbeat_at, r.started_at) <= ?
ORDER BY COALESCE(h.latest_heartbeat_at, r.started_at) ASC
LIMIT ?`,
[cutoff, normalizedLimit]
);
const recovered = [];
for (const row of rows) {
const ageMs = Math.max(0, nowMs() - Number(row.started_at ?? 0));
const heartbeatAt = row.latest_heartbeat_at == null ? null : Number(row.latest_heartbeat_at);
const heartbeatAgeMs = Math.max(0, nowMs() - Number(row.latest_heartbeat_at ?? row.started_at ?? 0));
const item = {
id: row.id,
requestId: row.request_id,
startedAt: Number(row.started_at ?? 0),
updatedAt: Number(row.updated_at ?? 0),
attempts: Number(row.attempts ?? 0),
heartbeatAt,
heartbeatAgeMs,
ageMs,
reason
};
if (!dryRun) {
const message = heartbeatAt == null ? `agent run recovered from stale running state after ${ageMs}ms without heartbeat` : `agent run recovered from stale running state after heartbeat was stale for ${heartbeatAgeMs}ms`;
const completedAt = nowMs();
const [update] = await pool2.query(
`UPDATE h5_agent_runs
SET status = 'failed', error_message = ?, updated_at = ?, completed_at = ?
WHERE id = ?
AND status = 'running'
AND started_at IS NOT NULL
AND COALESCE(
(SELECT MAX(created_at)
FROM h5_agent_run_events
WHERE run_id = h5_agent_runs.id AND event_type = 'worker_heartbeat'),
started_at
) <= ?`,
[message, completedAt, completedAt, row.id, cutoff]
);
if (Number(update?.affectedRows ?? 0) === 0) continue;
await appendEvent(row.id, "stale_recovered", {
reason,
staleMs: normalizedStaleMs,
ageMs,
heartbeatAt,
heartbeatAgeMs,
status: "failed",
error: message
});
}
recovered.push(item);
}
return {
dryRun,
staleMs: normalizedStaleMs,
cutoff,
considered: rows.length,
recovered: dryRun ? 0 : recovered.length,
runs: recovered
};
}
async function dispatchQueuedRuns({ limit = maxConcurrentRuns } = {}) {
const staleRecovery = await recoverStaleRunningRuns({
staleMs: runTimeoutMs,
limit: Math.max(maxConcurrentRuns, Number(limit) || maxConcurrentRuns),
dryRun: false,
reason: "worker_dispatch_stale_recovery"
});
const dispatchLimit = Math.max(1, Number(limit) || maxConcurrentRuns);
const available = Math.max(0, maxConcurrentRuns - inFlight.size - queuedDispatches.length);
if (available <= 0) {
return {
considered: 0,
dispatched: 0,
staleRecovery,
queue: await getQueueStatus()
};
}
const take = Math.min(dispatchLimit, available);
const [rows] = await pool2.query(
`SELECT id
FROM h5_agent_runs
WHERE status IN ('queued', 'retryable')
ORDER BY updated_at ASC
LIMIT ?`,
[take]
);
for (const row of rows) {
dispatchRun(row.id);
}
return {
considered: rows.length,
dispatched: rows.length,
staleRecovery,
queue: await getQueueStatus()
};
}
return {
createRun,
getRunForUser,
dispatchRun,
getQueueStatus,
dispatchQueuedRuns,
recoverStaleRunningRuns
};
}
// db.mjs
import mysql from "mysql2/promise";
import path2 from "node:path";
import { fileURLToPath } from "node:url";
// mindspace.mjs
import crypto2 from "node:crypto";
var DEFAULT_SPACE_QUOTA_BYTES = 5 * 1024 * 1024;
var DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
var SYSTEM_CATEGORIES = Object.freeze([
{
code: "oa",
name: "OA \u5DE5\u4F5C\u533A",
visibilityPolicy: "private",
aiAccessPolicy: "selected_assets",
publishPolicy: "derivative_only",
sortOrder: 10
},
{
code: "public",
name: "\u516C\u5F00\u533A",
visibilityPolicy: "public_candidate",
aiAccessPolicy: "selected_assets",
publishPolicy: "security_scan_required",
sortOrder: 20
},
{
code: "draft",
name: "\u9875\u9762\u8349\u7A3F",
visibilityPolicy: "private",
aiAccessPolicy: "job_output",
publishPolicy: "security_scan_required",
sortOrder: 40
},
{
code: "archive",
name: "\u5F52\u6863\u533A",
visibilityPolicy: "private",
aiAccessPolicy: "none",
publishPolicy: "forbidden",
sortOrder: 50
}
]);
async function initializeDefaultSpace(db, userId, {
quotaBytes = DEFAULT_SPACE_QUOTA_BYTES,
spaceName = "\u6211\u7684\u7A7A\u95F4",
now = Date.now(),
idFactory = () => crypto2.randomUUID()
} = {}) {
const spaceId = idFactory();
await db.query(
`INSERT INTO h5_user_spaces
(id, user_id, space_name, quota_bytes, used_bytes, reserved_bytes, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 0, 0, 'active', ?, ?)
ON DUPLICATE KEY UPDATE user_id = user_id`,
[spaceId, userId, spaceName, quotaBytes, now, now]
);
const [spaces] = await db.query(
`SELECT id FROM h5_user_spaces WHERE user_id = ? LIMIT 1`,
[userId]
);
const resolvedSpaceId = spaces[0]?.id;
if (!resolvedSpaceId) {
throw new Error("\u7528\u6237\u7A7A\u95F4\u521D\u59CB\u5316\u5931\u8D25");
}
for (const category of SYSTEM_CATEGORIES) {
await db.query(
`INSERT INTO h5_space_categories
(id, user_id, space_id, category_code, category_name, visibility_policy,
ai_access_policy, publish_policy, is_system, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
ON DUPLICATE KEY UPDATE
category_name = VALUES(category_name),
visibility_policy = VALUES(visibility_policy),
ai_access_policy = VALUES(ai_access_policy),
publish_policy = VALUES(publish_policy),
is_system = 1,
sort_order = VALUES(sort_order),
updated_at = VALUES(updated_at)`,
[
idFactory(),
userId,
resolvedSpaceId,
category.code,
category.name,
category.visibilityPolicy,
category.aiAccessPolicy,
category.publishPolicy,
category.sortOrder,
now,
now
]
);
}
return resolvedSpaceId;
}
// db.mjs
var __dirname = path2.dirname(fileURLToPath(import.meta.url));
function isDatabaseConfigured() {
return Boolean(
process.env.DATABASE_URL || process.env.MYSQL_HOST && process.env.MYSQL_DATABASE
);
}
function resolvePoolOptions() {
const connectionLimit = Math.max(1, Number(process.env.MYSQL_POOL_SIZE ?? 50));
const queueLimit = Math.max(0, Number(process.env.MYSQL_POOL_QUEUE_LIMIT ?? 0));
return { waitForConnections: true, connectionLimit, queueLimit };
}
function createDbPool() {
if (!isDatabaseConfigured()) {
throw new Error("MySQL \u672A\u914D\u7F6E\uFF0C\u8BF7\u8BBE\u7F6E DATABASE_URL \u6216 MYSQL_* \u73AF\u5883\u53D8\u91CF");
}
const poolOptions = resolvePoolOptions();
if (process.env.DATABASE_URL) {
return mysql.createPool({ uri: process.env.DATABASE_URL, ...poolOptions });
}
return mysql.createPool({
host: process.env.MYSQL_HOST ?? "localhost",
port: Number(process.env.MYSQL_PORT ?? 3306),
user: process.env.MYSQL_USER ?? "boot",
password: process.env.MYSQL_PASSWORD ?? "",
database: process.env.MYSQL_DATABASE ?? "tkmind",
...poolOptions
});
}
// llm-providers.mjs
import crypto3 from "node:crypto";
import fs2 from "node:fs";
import os from "node:os";
import path3 from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { Agent, fetch as undiciFetch } from "undici";
var CUSTOM_PROVIDER_ID = "__custom__";
var LLM_PROVIDER_CATALOG = [
{
id: CUSTOM_PROVIDER_ID,
label: "\u81EA\u5B9A\u4E49 OpenAI \u517C\u5BB9",
kind: "custom",
apiKeyEnv: null,
defaultModel: "",
models: []
},
{
id: "custom_deepseek",
label: "DeepSeek",
kind: "builtin",
apiKeyEnv: "DEEPSEEK_API_KEY",
defaultModel: "deepseek-chat",
models: ["deepseek-chat", "deepseek-reasoner"]
},
{
id: "openai",
label: "OpenAI",
kind: "builtin",
apiKeyEnv: "OPENAI_API_KEY",
defaultModel: "gpt-4o",
models: ["gpt-4o", "gpt-4o-mini"]
},
{
id: "openrouter",
label: "OpenRouter",
kind: "builtin",
apiKeyEnv: "OPENROUTER_API_KEY",
defaultModel: "anthropic/claude-sonnet-4",
models: ["anthropic/claude-sonnet-4", "openai/gpt-4o"]
},
{
id: "anthropic",
label: "Anthropic",
kind: "builtin",
apiKeyEnv: "ANTHROPIC_API_KEY",
defaultModel: "claude-sonnet-4-20250514",
models: ["claude-sonnet-4-20250514", "claude-3-5-haiku-20241022"]
},
{
id: "custom_qwen",
label: "Qwen VL (\u901A\u4E49\u5343\u95EE)",
kind: "builtin",
apiKeyEnv: null,
// stored as custom provider; apiUrl is injected at createKey time
apiUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
defaultModel: "qwen-vl-max",
models: ["qwen-vl-max", "qwen-vl-max-latest", "qwen-vl-plus", "qwen-plus", "qwen-turbo"]
}
];
var LLM_EXECUTOR_CATALOG = [
{
id: "goose",
label: "Goose",
description: "\u7B56\u7565\u5224\u65AD\u3001\u5206\u6790\u3001\u603B\u7ED3\u548C\u8F7B\u91CF\u4EFB\u52A1\u7F16\u6392",
purposes: ["default"]
},
{
id: "aider",
label: "Aider",
description: "\u5C0F\u8303\u56F4\u4EE3\u7801\u4FEE\u6539\u548C\u8865\u4E01\u5F0F\u4FEE\u590D",
purposes: ["default"]
},
{
id: "openhands",
label: "OpenHands",
description: "\u590D\u6742\u4ED3\u5E93\u4EFB\u52A1\u3001\u591A\u6587\u4EF6\u6539\u9020\u548C\u547D\u4EE4\u6267\u884C",
purposes: ["default"]
}
];
var catalogById = Object.fromEntries(LLM_PROVIDER_CATALOG.map((item) => [item.id, item]));
var executorById = Object.fromEntries(LLM_EXECUTOR_CATALOG.map((item) => [item.id, item]));
var BUILTIN_PROVIDER_TEST_URLS = {
custom_deepseek: process.env.DEEPSEEK_API_BASE_URL ?? "https://api.deepseek.com/v1",
openai: process.env.OPENAI_API_BASE_URL ?? "https://api.openai.com/v1",
openrouter: process.env.OPENROUTER_API_BASE_URL ?? "https://openrouter.ai/api/v1"
};
var insecureDispatcher = new Agent({
connect: { rejectUnauthorized: false }
});
function resolveEncryptionKey(explicitKey) {
const raw = explicitKey ?? process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY ?? "local-dev-secret";
return crypto3.createHash("sha256").update(raw).digest();
}
function encryptSecret(plaintext, encryptionKey) {
const key = resolveEncryptionKey(encryptionKey);
const iv = crypto3.randomBytes(12);
const cipher = crypto3.createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return {
ciphertext: encrypted.toString("base64"),
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64")
};
}
function decryptSecret({ ciphertext, iv, tag }, encryptionKey) {
const key = resolveEncryptionKey(encryptionKey);
const decipher = crypto3.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"));
decipher.setAuthTag(Buffer.from(tag, "base64"));
const plain = Buffer.concat([
decipher.update(Buffer.from(ciphertext, "base64")),
decipher.final()
]);
return plain.toString("utf8");
}
function maskApiKey(apiKey) {
if (!apiKey) return "";
if (apiKey.length <= 8) return "*".repeat(apiKey.length);
const head = apiKey.slice(0, 4);
const tail = apiKey.slice(-4);
return `${head}${"*".repeat(Math.max(apiKey.length - 8, 4))}${tail}`;
}
function parseModelList(raw) {
if (Array.isArray(raw)) {
return [...new Set(raw.map((item) => String(item).trim()).filter(Boolean))];
}
return [
...new Set(
String(raw ?? "").split(/[\n,]/).map((item) => item.trim()).filter(Boolean)
)
];
}
function normalizeApiUrl(raw) {
const trimmed = String(raw ?? "").trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed.replace(/\/+$/, "");
return `http://${trimmed.replace(/\/+$/, "")}`;
}
function resolveApiBaseUrl(apiUrl) {
const normalized = normalizeApiUrl(apiUrl);
if (!normalized) return "";
if (normalized.endsWith("/chat/completions")) {
return normalized.slice(0, -"/chat/completions".length).replace(/\/+$/, "");
}
return normalized.replace(/\/+$/, "");
}
function resolveChatCompletionsUrl(apiUrl) {
const normalized = normalizeApiUrl(apiUrl);
if (!normalized) return "";
if (normalized.endsWith("/chat/completions")) return normalized;
if (normalized.endsWith("/v1")) return `${normalized}/chat/completions`;
return `${normalized}/chat/completions`;
}
var RELAY_BOOTSTRAP = {
name: process.env.H5_RELAY_BOOTSTRAP_NAME ?? "Relay Buyer Ollama",
apiUrl: process.env.H5_RELAY_BOOTSTRAP_URL ?? "http://127.0.0.1:18300/relay/buyer/v1/chat/completions",
apiKey: process.env.H5_RELAY_BOOTSTRAP_API_KEY ?? "UqyHPKSSEZq0-oPnl8sru-7hZcJ2anPUL1yAVk866Vo",
models: parseModelList(process.env.H5_RELAY_BOOTSTRAP_MODELS ?? "qwen2.5:3b"),
defaultModel: process.env.H5_RELAY_BOOTSTRAP_MODEL ?? "qwen2.5:3b",
relayProvider: process.env.H5_RELAY_BOOTSTRAP_PROVIDER ?? "ollama"
};
var LOCAL_LLM_FALLBACK = {
enabled: process.env.H5_LOCAL_LLM_FALLBACK !== "0",
providerId: process.env.H5_LOCAL_LLM_PROVIDER_ID ?? "custom_local_ollama_7b",
displayName: process.env.H5_LOCAL_LLM_NAME ?? "Local Ollama 7B",
apiUrl: process.env.H5_LOCAL_LLM_URL ?? "http://127.0.0.1:11434/v1/chat/completions",
apiKey: process.env.H5_LOCAL_LLM_API_KEY ?? "ollama",
model: process.env.H5_LOCAL_LLM_MODEL ?? "qwen2.5:3b"
};
var cachedLocalFallbackProviderId = null;
async function testRelayConnection({ apiUrl, apiKey, model, relayProvider }, fetchImpl = undiciFetch) {
const url = resolveChatCompletionsUrl(apiUrl);
if (!url) return { ok: false, message: "API \u5730\u5740\u65E0\u6548" };
if (!apiKey) return { ok: false, message: "\u7F3A\u5C11 API Key" };
if (!model) return { ok: false, message: "\u7F3A\u5C11\u6A21\u578B" };
const started = Date.now();
const body = {
model,
messages: [{ role: "user", content: "Hello" }],
stream: false,
...relayProvider ? { provider: relayProvider } : {}
};
const upstream = await fetchImpl(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`
},
body: JSON.stringify(body),
dispatcher: url.startsWith("https://") ? insecureDispatcher : void 0
});
const latencyMs = Date.now() - started;
const text = await upstream.text().catch(() => "");
if (!upstream.ok) {
let detail = text.slice(0, 500) || "(\u7A7A\u54CD\u5E94)";
if (detail === "{}") {
detail = "\u7A7A JSON \u54CD\u5E94";
}
const statusHint = upstream.status === 401 ? "Bearer Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" : upstream.status === 404 ? "\u5730\u5740\u4E0D\u5B58\u5728\uFF0C\u8BF7\u68C0\u67E5 API URL" : upstream.status === 400 ? "\u8BF7\u6C42\u53C2\u6570\u88AB\u62D2\u7EDD\uFF0C\u8BF7\u68C0\u67E5 model / provider" : `HTTP ${upstream.status}`;
return {
ok: false,
latencyMs,
message: `Relay ${upstream.status} ${statusHint}\uFF1A${detail}`
};
}
let data;
try {
data = JSON.parse(text);
} catch {
return { ok: false, latencyMs, message: "\u54CD\u5E94\u4E0D\u662F JSON" };
}
const reply = data?.choices?.[0]?.message?.content ?? data?.message?.content ?? data?.output ?? null;
return {
ok: true,
latencyMs,
model,
reply: reply ? String(reply).slice(0, 300) : "(\u8054\u901A\u6210\u529F\uFF0C\u65E0\u6587\u672C\u5185\u5BB9)"
};
}
async function testLocalLlmConnection(fetchImpl = undiciFetch) {
if (!LOCAL_LLM_FALLBACK.enabled) {
return { ok: false, message: "\u672C\u5730 LLM fallback \u5DF2\u7981\u7528" };
}
return testRelayConnection(
{
apiUrl: LOCAL_LLM_FALLBACK.apiUrl,
apiKey: LOCAL_LLM_FALLBACK.apiKey,
model: LOCAL_LLM_FALLBACK.model,
relayProvider: null
},
fetchImpl
);
}
function parseModelsJson(raw) {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parseModelList(parsed) : [];
} catch {
return [];
}
}
function rowToPublic(row, catalogItem, apiKeyForMask) {
const providerKind = row.provider_kind ?? "builtin";
const models = providerKind === "custom" ? parseModelsJson(row.models_json) : catalogItem?.models ?? [];
return {
id: row.id,
providerId: row.provider_id,
providerKind,
providerLabel: providerKind === "custom" ? row.name : catalogItem?.label ?? row.provider_id,
name: row.name,
defaultModel: row.default_model,
models,
apiUrl: row.api_url ?? null,
basePath: row.base_path ?? null,
engine: row.engine ?? "openai",
relayProvider: row.relay_provider ?? null,
goosedProviderId: row.goosed_provider_id ?? null,
status: row.status,
isSelected: Boolean(row.is_selected),
isVisionSelected: Boolean(row.is_vision_selected),
apiKeyMasked: maskApiKey(apiKeyForMask),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at)
};
}
function profileFromRow(row, decryptRow) {
const providerKind = row.provider_kind ?? "builtin";
const models = providerKind === "custom" ? parseModelsJson(row.models_json) : [row.default_model];
return {
providerKind,
providerId: row.provider_id,
goosedProviderId: row.goosed_provider_id ?? null,
name: row.name,
defaultModel: row.default_model,
apiKey: decryptRow(row),
apiUrl: row.api_url ?? null,
basePath: row.base_path ?? null,
engine: row.engine ?? "openai",
relayProvider: row.relay_provider ?? null,
models
};
}
function normalizeExecutor(raw) {
const executor = String(raw ?? "").trim().toLowerCase();
return executorById[executor] ? executor : null;
}
function normalizePurpose(raw) {
return String(raw ?? "default").trim() || "default";
}
function rowToExecutorBinding(row, keyPublic = null) {
const executor = normalizeExecutor(row.executor);
const meta = executorById[executor] ?? null;
return {
id: row.id,
executor,
executorLabel: meta?.label ?? row.executor,
executorDescription: meta?.description ?? "",
purpose: row.purpose ?? "default",
providerKeyId: row.provider_key_id ?? null,
providerName: keyPublic?.name ?? null,
providerLabel: keyPublic?.providerLabel ?? null,
providerId: keyPublic?.providerId ?? null,
model: row.model ?? "",
enabled: Boolean(row.enabled),
availableModels: keyPublic?.models ?? [],
createdAt: row.created_at ? Number(row.created_at) : null,
updatedAt: row.updated_at ? Number(row.updated_at) : null
};
}
function executorEnvForProfile(executor, profile, model, { includeSecret = false } = {}) {
const apiKeyValue = includeSecret ? profile.apiKey : "[hidden]";
const providerId = profile.providerId;
const resolvedApiUrl = profile.apiUrl || BUILTIN_PROVIDER_TEST_URLS[providerId] || "";
const baseUrl = resolveApiBaseUrl(resolvedApiUrl);
const common = {
TKMIND_EXECUTOR: executor,
TKMIND_EXECUTOR_PROVIDER: providerId,
TKMIND_EXECUTOR_MODEL: model
};
if (baseUrl) common.TKMIND_EXECUTOR_API_BASE = baseUrl;
if (includeSecret) common.TKMIND_EXECUTOR_API_KEY = profile.apiKey;
if (executor === "aider") {
const aiderModel = aiderModelNameForProvider(providerId, model);
const env = {
...common,
AIDER_MODEL: aiderModel
};
if (providerId === "anthropic") {
env.AIDER_ANTHROPIC_API_KEY = apiKeyValue;
env.ANTHROPIC_API_KEY = apiKeyValue;
} else {
env.AIDER_OPENAI_API_KEY = apiKeyValue;
env.OPENAI_API_KEY = apiKeyValue;
if (providerId === "openrouter") env.OPENROUTER_API_KEY = apiKeyValue;
if (providerId === "custom_deepseek") env.DEEPSEEK_API_KEY = apiKeyValue;
}
if (baseUrl) {
env.AIDER_OPENAI_API_BASE = baseUrl;
env.OPENAI_API_BASE = baseUrl;
}
return env;
}
if (executor === "openhands") {
return {
...common,
LLM_MODEL: model,
LLM_API_KEY: apiKeyValue,
...baseUrl ? { LLM_BASE_URL: baseUrl } : {}
};
}
return {
...common,
GOOSE_PROVIDER: profile.providerKind === "custom" ? profile.goosedProviderId ?? profile.providerId : profile.providerId,
GOOSE_MODEL: model,
TKMIND_PROVIDER: profile.providerKind === "custom" ? profile.goosedProviderId ?? profile.providerId : profile.providerId,
TKMIND_MODEL: model
};
}
function aiderModelNameForProvider(providerId, model) {
const normalizedModel = String(model ?? "").trim();
if (!normalizedModel) return normalizedModel;
if (String(providerId ?? "").trim() === "custom_deepseek") {
return normalizedModel.startsWith("deepseek/") ? normalizedModel : `deepseek/${normalizedModel}`;
}
return normalizedModel;
}
function launchLogDir() {
return path3.join(os.tmpdir(), "memindadm-launches");
}
function normalizeLaunchMode(mode) {
const normalized = String(mode ?? "headless").trim().toLowerCase();
return normalized === "serve" ? "serve" : "headless";
}
function resolveExecutorCommand(executor) {
const candidates = executor === "aider" ? [process.env.AIDER_BIN, process.env.GOOSE_AIDER_BIN, "aider"] : executor === "openhands" ? [process.env.OPENHANDS_BIN, process.env.GOOSE_OPENHANDS_BIN, "openhands"] : [executor];
return candidates.map((item) => String(item ?? "").trim()).find(Boolean) ?? executor;
}
function commandExists(command) {
if (path3.isAbsolute(command)) {
try {
fs2.accessSync(command, fs2.constants.X_OK);
return true;
} catch {
return false;
}
}
const probe = spawnSync("sh", ["-lc", `command -v ${JSON.stringify(command).slice(1, -1)}`], {
stdio: "ignore"
});
return probe.status === 0;
}
function spawnDetachedExecutor(plan, { logDir = launchLogDir() } = {}) {
if (!plan?.ok) {
return plan;
}
if (!plan.command) {
return {
ok: false,
executor: plan.executor ?? null,
message: "\u542F\u52A8\u547D\u4EE4\u7F3A\u5931"
};
}
if (!commandExists(plan.command)) {
return {
ok: false,
executor: plan.executor ?? null,
message: `${plan.command} \u672A\u5B89\u88C5\u6216\u4E0D\u5728 PATH \u4E2D`
};
}
fs2.mkdirSync(logDir, { recursive: true });
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
const logFile = path3.join(
logDir,
`${plan.executor ?? "executor"}-${stamp}-${crypto3.randomUUID().slice(0, 8)}.log`
);
const fd = fs2.openSync(logFile, "a");
try {
const child = spawn(plan.command, plan.args ?? [], {
cwd: plan.cwd ?? process.cwd(),
env: {
...process.env,
...plan.env ?? {}
},
detached: true,
stdio: ["ignore", fd, fd]
});
child.unref();
return {
ok: true,
executor: plan.executor ?? null,
pid: child.pid ?? null,
command: plan.command,
args: plan.args ?? [],
cwd: plan.cwd ?? process.cwd(),
logFile,
notes: plan.notes ?? []
};
} catch (err) {
return {
ok: false,
executor: plan.executor ?? null,
message: err instanceof Error ? err.message : "\u542F\u52A8\u5931\u8D25",
logFile
};
} finally {
try {
fs2.closeSync(fd);
} catch {
}
}
}
function isProcessAlive(pid) {
if (!pid || Number.isNaN(Number(pid))) return false;
try {
process.kill(Number(pid), 0);
return true;
} catch (err) {
if (err && typeof err === "object" && "code" in err && err.code === "EPERM") return true;
return false;
}
}
function launchStateKey(executor, purpose = "default") {
return `${normalizeExecutor(executor)}:${normalizePurpose(purpose)}`;
}
function buildExecutorLaunchPlan(runtime, options = {}) {
if (!runtime?.ok) {
return {
ok: false,
executor: runtime?.executor ?? null,
message: runtime?.message ?? "\u8FD0\u884C\u914D\u7F6E\u672A\u5C31\u7EEA"
};
}
const mode = normalizeLaunchMode(options.mode ?? "headless");
const cwd = String(options.cwd ?? process.cwd());
const instruction = String(options.instruction ?? "").trim();
const env = runtime.env ?? {};
if (runtime.executor === "goose") {
return {
ok: false,
executor: "goose",
message: "Goose \u4F5C\u4E3A\u73B0\u6709\u670D\u52A1\u5165\u53E3\uFF0C\u4E0D\u901A\u8FC7\u672C\u65B9\u6CD5\u542F\u52A8"
};
}
if (runtime.executor === "aider") {
const hasInstruction = Boolean(instruction);
const command = resolveExecutorCommand("aider");
const model = aiderModelNameForProvider(env.TKMIND_EXECUTOR_PROVIDER, runtime.model);
return {
ok: true,
executor: "aider",
cwd,
command,
args: [
"--model",
model,
...hasInstruction ? ["--message", instruction] : [],
"--yes-always"
],
env,
notes: [
"Aider \u901A\u8FC7\u7EDF\u4E00\u6A21\u578B\u4E2D\u5FC3\u63D0\u4F9B\u7684 API Key / Base URL \u8FD0\u884C\u3002",
"\u5B9E\u9645\u542F\u52A8\u65F6\u5EFA\u8BAE\u5728\u4ED3\u5E93\u76EE\u5F55\u4E2D\u6267\u884C\u3002"
]
};
}
if (runtime.executor === "openhands") {
if (mode === "headless" && !instruction) {
return {
ok: false,
executor: "openhands",
message: "OpenHands headless \u6A21\u5F0F\u9700\u8981 instruction"
};
}
const command = resolveExecutorCommand("openhands");
const args2 = mode === "serve" ? ["serve", "--mount-cwd"] : ["--headless", "--json", "--override-with-envs", "--task", instruction];
return {
ok: true,
executor: "openhands",
cwd,
command,
args: args2,
env,
notes: [
mode === "serve" ? "OpenHands GUI Server \u4F9D\u8D56 Docker\uFF0C\u9002\u5408\u4EBA\u5DE5\u4EA4\u4E92\u8C03\u8BD5\u3002" : "Headless \u6A21\u5F0F\u9002\u5408\u540E\u7EED\u548C\u540E\u53F0\u8C03\u5EA6\u5BF9\u63A5\u3002"
]
};
}
return {
ok: false,
executor: runtime.executor,
message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668"
};
}
async function goosedApiFetch(apiTarget, apiSecret, pathname, init = {}, fetchImpl = undiciFetch) {
const url = new URL(pathname, apiTarget);
const headers = {
...init.headers ?? {},
"X-Secret-Key": apiSecret
};
if (init.body && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
const dispatcher = apiTarget.startsWith("https://") ? insecureDispatcher : void 0;
return fetchImpl(url, { ...init, headers, dispatcher });
}
async function writeGoosedConfig(apiTarget, apiSecret, key, value, isSecret, fetchImpl) {
const upstream = await goosedApiFetch(
apiTarget,
apiSecret,
"/config/upsert",
{
method: "POST",
body: JSON.stringify({ key, value, is_secret: isSecret })
},
fetchImpl
);
if (upstream.ok) return { ok: true, skipped: false };
if (upstream.status === 404) return { ok: false, skipped: true };
const text = await upstream.text().catch(() => "");
throw new Error(`\u540C\u6B65 TKMind Agent \u914D\u7F6E ${key} \u5931\u8D25: ${text || upstream.status}`);
}
async function ensureLocalFallbackProviderOnGoosed(apiTarget, apiSecret, fetchImpl = undiciFetch) {
if (!LOCAL_LLM_FALLBACK.enabled) {
throw new Error("\u672C\u5730 LLM fallback \u5DF2\u7981\u7528");
}
const profile = {
providerKind: "custom",
name: LOCAL_LLM_FALLBACK.displayName,
apiUrl: LOCAL_LLM_FALLBACK.apiUrl,
apiKey: LOCAL_LLM_FALLBACK.apiKey,
models: [LOCAL_LLM_FALLBACK.model],
defaultModel: LOCAL_LLM_FALLBACK.model,
goosedProviderId: cachedLocalFallbackProviderId,
engine: "openai"
};
const goosedProviderId = await upsertCustomProviderOnGoosed(
apiTarget,
apiSecret,
profile,
fetchImpl
);
cachedLocalFallbackProviderId = goosedProviderId;
return goosedProviderId;
}
async function updateSessionProvider(apiFetchImpl, sessionId, provider, model) {
const upstream = await apiFetchImpl("/agent/update_provider", {
method: "POST",
body: JSON.stringify({
session_id: sessionId,
provider,
model
})
});
if (!upstream.ok) {
const text = await upstream.text().catch(() => "");
throw new Error(text || `\u5207\u6362\u4F1A\u8BDD Provider \u5931\u8D25: ${upstream.status}`);
}
}
async function upsertCustomProviderOnGoosed(apiTarget, apiSecret, profile, fetchImpl) {
const headers = profile.relayProvider ? { "X-Provider": profile.relayProvider } : void 0;
const body = {
engine: profile.engine || "openai",
display_name: profile.name,
api_url: profile.apiUrl,
api_key: profile.apiKey,
models: profile.models,
supports_streaming: true,
requires_auth: true,
...profile.basePath ? { base_path: profile.basePath } : {},
...headers ? { headers } : {}
};
if (profile.goosedProviderId) {
const upstream2 = await goosedApiFetch(
apiTarget,
apiSecret,
`/config/custom-providers/${encodeURIComponent(profile.goosedProviderId)}`,
{ method: "PUT", body: JSON.stringify(body) },
fetchImpl
);
if (!upstream2.ok) {
const text = await upstream2.text().catch(() => "");
throw new Error(`\u66F4\u65B0 TKMind Agent \u81EA\u5B9A\u4E49 provider \u5931\u8D25: ${text || upstream2.status}`);
}
return profile.goosedProviderId;
}
const upstream = await goosedApiFetch(
apiTarget,
apiSecret,
"/config/custom-providers",
{ method: "POST", body: JSON.stringify(body) },
fetchImpl
);
if (!upstream.ok) {
const text = await upstream.text().catch(() => "");
throw new Error(`\u521B\u5EFA TKMind Agent \u81EA\u5B9A\u4E49 provider \u5931\u8D25: ${text || upstream.status}`);
}
const data = await upstream.json();
return data.provider_name;
}
async function removeCustomProviderOnGoosed(apiTarget, apiSecret, goosedProviderId, fetchImpl) {
if (!goosedProviderId) return;
const upstream = await goosedApiFetch(
apiTarget,
apiSecret,
`/config/custom-providers/${encodeURIComponent(goosedProviderId)}`,
{ method: "DELETE" },
fetchImpl
);
if (!upstream.ok && upstream.status !== 404) {
const text = await upstream.text().catch(() => "");
throw new Error(`\u5220\u9664 TKMind Agent \u81EA\u5B9A\u4E49 provider \u5931\u8D25: ${text || upstream.status}`);
}
}
async function syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl) {
const catalogItem = catalogById[profile.providerId];
if (!catalogItem?.apiKeyEnv) {
throw new Error(`\u672A\u77E5\u5185\u7F6E provider: ${profile.providerId}`);
}
await writeGoosedConfig(
apiTarget,
apiSecret,
catalogItem.apiKeyEnv,
profile.apiKey,
true,
fetchImpl
);
await writeGoosedConfig(
apiTarget,
apiSecret,
"GOOSE_PROVIDER",
profile.providerId,
false,
fetchImpl
);
await writeGoosedConfig(
apiTarget,
apiSecret,
"GOOSE_MODEL",
profile.defaultModel,
false,
fetchImpl
);
await writeGoosedConfig(
apiTarget,
apiSecret,
"TKMIND_PROVIDER",
profile.providerId,
false,
fetchImpl
);
await writeGoosedConfig(
apiTarget,
apiSecret,
"TKMIND_MODEL",
profile.defaultModel,
false,
fetchImpl
);
}
async function syncProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl = undiciFetch) {
if (profile.providerKind === "custom") {
const goosedProviderId = await upsertCustomProviderOnGoosed(
apiTarget,
apiSecret,
profile,
fetchImpl
);
await writeGoosedConfig(
apiTarget,
apiSecret,
"GOOSE_PROVIDER",
goosedProviderId,
false,
fetchImpl
);
await writeGoosedConfig(
apiTarget,
apiSecret,
"GOOSE_MODEL",
profile.defaultModel,
false,
fetchImpl
);
await writeGoosedConfig(
apiTarget,
apiSecret,
"TKMIND_PROVIDER",
goosedProviderId,
false,
fetchImpl
);
await writeGoosedConfig(
apiTarget,
apiSecret,
"TKMIND_MODEL",
profile.defaultModel,
false,
fetchImpl
);
return goosedProviderId;
}
await syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl);
return profile.providerId;
}
function isCustomPayload(payload) {
return payload?.providerId === CUSTOM_PROVIDER_ID || payload?.providerKind === "custom";
}
function validateCustomPayload(payload) {
const name = String(payload?.name ?? "").trim();
const apiKey = String(payload?.apiKey ?? "").trim();
const apiUrl = normalizeApiUrl(payload?.apiUrl);
const models = parseModelList(payload?.models);
const defaultModel = String(payload?.defaultModel ?? "").trim() || models[0] || "";
if (!name) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" };
if (!apiUrl) return { ok: false, message: "\u8BF7\u586B\u5199 API \u5730\u5740" };
if (!apiKey) return { ok: false, message: "\u8BF7\u586B\u5199 API Key / Bearer Token" };
if (models.length === 0) return { ok: false, message: "\u8BF7\u81F3\u5C11\u586B\u5199\u4E00\u4E2A\u6A21\u578B" };
if (!models.includes(defaultModel)) {
return { ok: false, message: "\u9ED8\u8BA4\u6A21\u578B\u5FC5\u987B\u5728\u6A21\u578B\u5217\u8868\u4E2D" };
}
return {
ok: true,
value: {
name,
apiKey,
apiUrl,
models,
defaultModel,
basePath: String(payload?.basePath ?? "").trim() || null,
engine: String(payload?.engine ?? "openai").trim() || "openai",
relayProvider: String(payload?.relayProvider ?? "").trim() || null
}
};
}
function createLlmProviderService(pool2, { apiTarget, apiTargets: apiTargets2 = [], apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {}) {
const launchStates = /* @__PURE__ */ new Map();
const syncTargets = [
...new Set([...Array.isArray(apiTargets2) ? apiTargets2 : [], apiTarget].filter(Boolean))
];
function catalogItem(providerId) {
return catalogById[providerId] ?? null;
}
function decryptRow(row) {
return decryptSecret(
{
ciphertext: row.api_key_ciphertext,
iv: row.api_key_iv,
tag: row.api_key_tag
},
encryptionKey
);
}
async function getRowById(id) {
const [rows] = await pool2.query("SELECT * FROM h5_llm_provider_keys WHERE id = ? LIMIT 1", [
id
]);
return rows[0] ?? null;
}
async function getSelectedRow() {
const [rows] = await pool2.query(
"SELECT * FROM h5_llm_provider_keys WHERE is_selected = 1 AND status = ? LIMIT 1",
["active"]
);
return rows[0] ?? null;
}
async function getExecutorBindingRow(executor, purpose = "default") {
const normalizedExecutor = normalizeExecutor(executor);
if (!normalizedExecutor) return null;
const [rows] = await pool2.query(
"SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1",
[normalizedExecutor, normalizePurpose(purpose)]
);
return rows[0] ?? null;
}
async function clearSelected() {
await pool2.query("UPDATE h5_llm_provider_keys SET is_selected = 0, updated_at = ?", [
Date.now()
]);
}
async function getVisionRow() {
const [rows] = await pool2.query(
"SELECT * FROM h5_llm_provider_keys WHERE is_vision_selected = 1 AND status = ? LIMIT 1",
["active"]
);
return rows[0] ?? null;
}
async function clearVisionSelected() {
await pool2.query("UPDATE h5_llm_provider_keys SET is_vision_selected = 0, updated_at = ?", [
Date.now()
]);
}
let visionKeyConfiguredCache = null;
const syncRowCache = /* @__PURE__ */ new Map();
const SYNC_ROW_CACHE_TTL_MS = 3e4;
async function syncRow(row, fetchImpl = apiFetchImpl) {
const cacheKey = `${row.id}:${row.updated_at}`;
const cached = syncRowCache.get(cacheKey);
if (cached && Date.now() - cached.syncedAt < SYNC_ROW_CACHE_TTL_MS && fetchImpl === apiFetchImpl) {
return cached.goosedProviderId;
}
const profile = profileFromRow(row, decryptRow);
const goosedProviderId = await syncProfileToGoosed(
apiTarget,
apiSecret,
profile,
fetchImpl
);
if (profile.providerKind === "custom" && goosedProviderId !== row.goosed_provider_id) {
await pool2.query(
"UPDATE h5_llm_provider_keys SET goosed_provider_id = ?, provider_id = ?, updated_at = ? WHERE id = ?",
[goosedProviderId, goosedProviderId, Date.now(), row.id]
);
}
if (fetchImpl === apiFetchImpl) {
syncRowCache.set(cacheKey, { goosedProviderId, syncedAt: Date.now() });
}
return goosedProviderId;
}
async function syncRowWithModel(row, model, fetchImpl = apiFetchImpl) {
const originalModel = row.default_model;
row.default_model = model || row.default_model;
try {
return await syncRow(row, fetchImpl);
} finally {
row.default_model = originalModel;
}
}
async function resolveExecutorProvider(executor, purpose = "default", fetchImpl = apiFetchImpl) {
const binding = await getExecutorBindingRow(executor, purpose);
if (!binding?.enabled) {
return { ok: false, message: `${executorById[executor]?.label ?? executor} \u672A\u542F\u7528\u6A21\u578B\u7ED1\u5B9A` };
}
if (!binding.provider_key_id) {
return { ok: false, message: `${executorById[executor]?.label ?? executor} \u672A\u7ED1\u5B9A Provider` };
}
const row = await getRowById(binding.provider_key_id);
if (!row || row.status !== "active") {
return { ok: false, message: "\u7ED1\u5B9A\u7684 Provider \u4E0D\u5B58\u5728\u6216\u5DF2\u7981\u7528" };
}
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
if (!publicRow.models.includes(binding.model)) {
return { ok: false, message: "\u7ED1\u5B9A\u6A21\u578B\u4E0D\u5728\u5F53\u524D Provider \u652F\u6301\u5217\u8868\u4E2D" };
}
try {
const goosedProviderId = await syncRowWithModel(row, binding.model, fetchImpl);
const profile = profileFromRow(row, decryptRow);
const providerId = profile.providerKind === "custom" ? goosedProviderId ?? profile.goosedProviderId ?? profile.providerId : profile.providerId;
return {
ok: true,
providerId,
model: binding.model,
source: "executor_binding",
executor,
purpose: binding.purpose
};
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : "\u540C\u6B65\u6267\u884C\u5668\u6A21\u578B\u914D\u7F6E\u5931\u8D25"
};
}
}
async function resolveExecutorRuntimeConfig(executor, { purpose = "default", includeSecret = false } = {}) {
const normalizedExecutor = normalizeExecutor(executor);
if (!normalizedExecutor) return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" };
const binding = await getExecutorBindingRow(normalizedExecutor, purpose);
if (!binding?.enabled) {
return {
ok: false,
executor: normalizedExecutor,
purpose,
message: `${executorById[normalizedExecutor]?.label ?? normalizedExecutor} \u672A\u542F\u7528\u6A21\u578B\u7ED1\u5B9A`
};
}
if (!binding.provider_key_id) {
return {
ok: false,
executor: normalizedExecutor,
purpose,
message: `${executorById[normalizedExecutor]?.label ?? normalizedExecutor} \u672A\u7ED1\u5B9A Provider`
};
}
const row = await getRowById(binding.provider_key_id);
if (!row || row.status !== "active") {
return {
ok: false,
executor: normalizedExecutor,
purpose,
message: "\u7ED1\u5B9A\u7684 Provider \u4E0D\u5B58\u5728\u6216\u5DF2\u7981\u7528"
};
}
const keyPublic = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
if (!keyPublic.models.includes(binding.model)) {
return {
ok: false,
executor: normalizedExecutor,
purpose,
message: "\u7ED1\u5B9A\u6A21\u578B\u4E0D\u5728\u5F53\u524D Provider \u652F\u6301\u5217\u8868\u4E2D"
};
}
const profile = profileFromRow(row, decryptRow);
const runtimeProfile = includeSecret ? profile : { ...profile, apiKey: "" };
return {
ok: true,
executor: normalizedExecutor,
executorLabel: executorById[normalizedExecutor]?.label ?? normalizedExecutor,
purpose: binding.purpose,
providerKeyId: row.id,
providerName: row.name,
providerKind: profile.providerKind,
providerId: profile.providerId,
providerLabel: keyPublic.providerLabel,
goosedProviderId: profile.goosedProviderId,
model: binding.model,
apiUrl: profile.apiUrl,
apiBaseUrl: resolveApiBaseUrl(profile.apiUrl),
apiKeyMasked: keyPublic.apiKeyMasked,
env: executorEnvForProfile(normalizedExecutor, runtimeProfile, binding.model, {
includeSecret
})
};
}
function getLaunchState(executor, purpose = "default") {
const key = launchStateKey(executor, purpose);
const current = launchStates.get(key);
if (!current) return null;
if (current.pid && !isProcessAlive(current.pid)) {
current.running = false;
current.stoppedAt = current.stoppedAt ?? Date.now();
current.exitReason = current.exitReason ?? "not_running";
launchStates.set(key, current);
}
return current;
}
function setLaunchState(executor, purpose, state) {
const key = launchStateKey(executor, purpose);
launchStates.set(key, {
...state,
executor: normalizeExecutor(executor),
purpose: normalizePurpose(purpose)
});
return launchStates.get(key);
}
function stateToPublic(state) {
if (!state) return null;
return {
executor: state.executor,
purpose: state.purpose,
pid: state.pid ?? null,
running: Boolean(state.running),
command: state.command ?? null,
args: state.args ?? [],
cwd: state.cwd ?? null,
logFile: state.logFile ?? null,
startedAt: state.startedAt ?? null,
stoppedAt: state.stoppedAt ?? null,
exitReason: state.exitReason ?? null,
mode: state.mode ?? null,
instruction: state.instruction ?? null
};
}
async function stopLaunchState(state, { force = false } = {}) {
if (!state?.pid) {
return { ok: false, message: "\u672A\u627E\u5230\u8FD0\u884C\u4E2D\u7684\u6267\u884C\u5668\u8FDB\u7A0B" };
}
if (!isProcessAlive(state.pid)) {
state.running = false;
state.stoppedAt = state.stoppedAt ?? Date.now();
state.exitReason = state.exitReason ?? "not_running";
return { ok: true, stopped: true, state: stateToPublic(state) };
}
try {
process.kill(state.pid, "SIGTERM");
if (!force) {
await new Promise((resolve) => setTimeout(resolve, 1500));
}
if (force || isProcessAlive(state.pid)) {
try {
process.kill(state.pid, "SIGKILL");
} catch {
}
}
state.running = false;
state.stoppedAt = Date.now();
state.exitReason = force ? "force_kill" : "sigterm";
return { ok: true, stopped: true, state: stateToPublic(state) };
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : "\u505C\u6B62\u5931\u8D25"
};
}
}
async function testSelectedRow(row) {
const profile = profileFromRow(row, decryptRow);
if (profile.providerKind === "custom") {
try {
return await testRelayConnection(
{
apiUrl: profile.apiUrl,
apiKey: profile.apiKey,
model: profile.defaultModel,
relayProvider: profile.relayProvider
},
apiFetchImpl
);
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : String(err)
};
}
}
return { ok: true, model: profile.defaultModel };
}
async function resolveSelectedProvider(fetchImpl = apiFetchImpl) {
const row = await getSelectedRow();
if (!row) {
return { ok: false, message: "\u8BF7\u5148\u542F\u7528 LLM \u914D\u7F6E" };
}
const profile = profileFromRow(row, decryptRow);
const selectedTest = await testSelectedRow(row);
if (!selectedTest.ok) {
return {
ok: false,
message: selectedTest.message ?? "\u5F53\u524D\u9009\u4E2D\u7684 LLM \u4E0D\u53EF\u7528"
};
}
try {
const goosedProviderId = await syncRow(row, fetchImpl);
const providerId = profile.providerKind === "custom" ? goosedProviderId ?? profile.goosedProviderId ?? profile.providerId : profile.providerId;
return {
ok: true,
providerId,
model: profile.defaultModel,
source: "selected"
};
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : "\u540C\u6B65 LLM \u914D\u7F6E\u5931\u8D25"
};
}
}
async function resolveLocalFallbackProvider(fetchImpl = apiFetchImpl) {
let localTest;
try {
localTest = await testLocalLlmConnection(apiFetchImpl);
} catch (err) {
localTest = {
ok: false,
message: err instanceof Error ? err.message : String(err)
};
}
if (!localTest.ok) {
return {
ok: false,
message: localTest.message ?? "\u672C\u5730 LLM \u4E0D\u53EF\u7528"
};
}
const providerId = await ensureLocalFallbackProviderOnGoosed(
apiTarget,
apiSecret,
fetchImpl
);
return {
ok: true,
providerId,
model: LOCAL_LLM_FALLBACK.model,
source: "local_fallback"
};
}
const goosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, apiFetchImpl);
return {
catalog: LLM_PROVIDER_CATALOG,
executorCatalog: LLM_EXECUTOR_CATALOG,
async listKeys() {
const [rows] = await pool2.query(
"SELECT * FROM h5_llm_provider_keys ORDER BY is_selected DESC, updated_at DESC"
);
return rows.map(
(row) => rowToPublic(row, catalogItem(row.provider_id), decryptRow(row))
);
},
async listExecutorBindings() {
const keys = await this.listKeys();
const keyMap = new Map(keys.map((key) => [key.id, key]));
const [rows] = await pool2.query(
'SELECT * FROM h5_llm_executor_bindings ORDER BY FIELD(executor, "goose", "aider", "openhands"), purpose'
);
const rowMap = new Map(
rows.map((row) => [`${row.executor}:${row.purpose}`, row])
);
return LLM_EXECUTOR_CATALOG.map((executor) => {
const row = rowMap.get(`${executor.id}:default`);
if (!row) {
return {
id: null,
executor: executor.id,
executorLabel: executor.label,
executorDescription: executor.description,
purpose: "default",
providerKeyId: null,
providerName: null,
providerLabel: null,
providerId: null,
model: "",
enabled: false,
availableModels: [],
createdAt: null,
updatedAt: null
};
}
return rowToExecutorBinding(row, keyMap.get(row.provider_key_id) ?? null);
});
},
async setExecutorBinding(executor, payload = {}) {
const normalizedExecutor = normalizeExecutor(executor);
if (!normalizedExecutor) return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" };
const purpose = normalizePurpose(payload.purpose);
const enabled = payload.enabled !== false;
const keyId = String(payload.keyId ?? payload.providerKeyId ?? "").trim() || null;
const model = String(payload.model ?? "").trim();
if (enabled && !keyId) return { ok: false, message: "\u8BF7\u9009\u62E9 Provider" };
if (enabled && !model) return { ok: false, message: "\u8BF7\u9009\u62E9\u6A21\u578B" };
let keyPublic = null;
if (keyId) {
const row2 = await getRowById(keyId);
if (!row2) return { ok: false, message: "Provider \u4E0D\u5B58\u5728" };
if (row2.status !== "active") return { ok: false, message: "Provider \u5DF2\u7981\u7528" };
keyPublic = rowToPublic(row2, catalogItem(row2.provider_id), decryptRow(row2));
if (model && !keyPublic.models.includes(model)) {
return { ok: false, message: "\u6A21\u578B\u4E0D\u5728\u8BE5 Provider \u652F\u6301\u5217\u8868\u4E2D" };
}
}
const existing = await getExecutorBindingRow(normalizedExecutor, purpose);
const now = Date.now();
if (existing) {
await pool2.query(
`UPDATE h5_llm_executor_bindings
SET provider_key_id = ?, model = ?, enabled = ?, updated_at = ?
WHERE id = ?`,
[keyId, model, enabled ? 1 : 0, now, existing.id]
);
} else {
await pool2.query(
`INSERT INTO h5_llm_executor_bindings
(id, executor, purpose, provider_key_id, model, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[crypto3.randomUUID(), normalizedExecutor, purpose, keyId, model, enabled ? 1 : 0, now, now]
);
}
const row = await getExecutorBindingRow(normalizedExecutor, purpose);
return { ok: true, binding: rowToExecutorBinding(row, keyPublic) };
},
async getExecutorRuntimeConfig(executor, options = {}) {
return resolveExecutorRuntimeConfig(executor, options);
},
async listExecutorRuntimeConfigs(options = {}) {
const configs = await Promise.all(
LLM_EXECUTOR_CATALOG.map(
(executor) => resolveExecutorRuntimeConfig(executor.id, options)
)
);
return configs;
},
async getExecutorLaunchPlan(executor, options = {}) {
const runtime = await resolveExecutorRuntimeConfig(executor, {
purpose: options.purpose ?? "default",
includeSecret: options.includeSecret ?? false
});
return buildExecutorLaunchPlan(runtime, options);
},
async listExecutorLaunchPlans(options = {}) {
const runtimes = await this.listExecutorRuntimeConfigs({
purpose: options.purpose ?? "default",
includeSecret: options.includeSecret ?? false
});
return runtimes.map((runtime) => buildExecutorLaunchPlan(runtime, options));
},
async listExecutorLaunchStates(options = {}) {
const purpose = normalizePurpose(options.purpose ?? "default");
return LLM_EXECUTOR_CATALOG.map((item) => stateToPublic(getLaunchState(item.id, purpose)));
},
async getExecutorLaunchState(executor, options = {}) {
const purpose = normalizePurpose(options.purpose ?? "default");
return stateToPublic(getLaunchState(executor, purpose));
},
async launchExecutor(executor, options = {}) {
const normalizedExecutor = normalizeExecutor(executor);
if (!normalizedExecutor) {
return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" };
}
const purpose = normalizePurpose(options.purpose ?? "default");
const mode = normalizeLaunchMode(options.mode ?? (normalizedExecutor === "openhands" ? "serve" : "headless"));
const instruction = String(options.instruction ?? "").trim();
const currentState = getLaunchState(normalizedExecutor, purpose);
if (currentState?.running) {
return {
ok: true,
executor: normalizedExecutor,
purpose,
running: true,
reused: true,
launch: stateToPublic(currentState)
};
}
if (normalizedExecutor === "goose") {
return {
ok: false,
executor: "goose",
message: "Goose \u4F5C\u4E3A\u73B0\u6709\u670D\u52A1\u5165\u53E3\uFF0C\u4E0D\u901A\u8FC7\u540E\u53F0\u76F4\u63A5\u542F\u52A8"
};
}
if (normalizedExecutor === "aider" && !instruction) {
return {
ok: false,
executor: "aider",
message: "Aider \u542F\u52A8\u9700\u8981 instruction"
};
}
if (normalizedExecutor === "openhands" && mode === "headless" && !instruction) {
return {
ok: false,
executor: "openhands",
message: "OpenHands headless \u542F\u52A8\u9700\u8981 instruction"
};
}
const runtime = await resolveExecutorRuntimeConfig(normalizedExecutor, {
purpose,
includeSecret: true
});
const plan = buildExecutorLaunchPlan(runtime, {
purpose,
mode,
cwd: options.cwd,
instruction,
includeSecret: true
});
if (!plan.ok) {
return plan;
}
const launch = spawnDetachedExecutor(plan, { logDir: options.logDir });
if (!launch.ok) return launch;
const state = setLaunchState(normalizedExecutor, purpose, {
pid: launch.pid ?? null,
running: true,
command: launch.command,
args: launch.args ?? [],
cwd: launch.cwd ?? plan.cwd,
logFile: launch.logFile,
startedAt: Date.now(),
mode,
instruction: instruction || null
});
return {
...launch,
purpose,
running: true,
launch: stateToPublic(state)
};
},
async stopExecutor(executor, options = {}) {
const normalizedExecutor = normalizeExecutor(executor);
if (!normalizedExecutor) {
return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" };
}
const purpose = normalizePurpose(options.purpose ?? "default");
const state = getLaunchState(normalizedExecutor, purpose);
if (!state) {
return { ok: false, executor: normalizedExecutor, purpose, message: "\u672A\u627E\u5230\u6267\u884C\u8BB0\u5F55" };
}
const result = await stopLaunchState(state, { force: Boolean(options.force) });
if (!result.ok) return { ok: false, executor: normalizedExecutor, purpose, message: result.message };
setLaunchState(normalizedExecutor, purpose, state);
return {
ok: true,
executor: normalizedExecutor,
purpose,
launch: result.state
};
},
async restartExecutor(executor, options = {}) {
const normalizedExecutor = normalizeExecutor(executor);
if (!normalizedExecutor) {
return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" };
}
const purpose = normalizePurpose(options.purpose ?? "default");
const state = getLaunchState(normalizedExecutor, purpose);
if (!state) {
return { ok: false, executor: normalizedExecutor, purpose, message: "\u672A\u627E\u5230\u53EF\u91CD\u542F\u7684\u6267\u884C\u8BB0\u5F55" };
}
await stopLaunchState(state, { force: true });
const launchOptions = {
purpose,
mode: options.mode ?? state.mode ?? (normalizedExecutor === "openhands" ? "serve" : "headless"),
cwd: options.cwd ?? state.cwd ?? process.cwd(),
instruction: options.instruction ?? state.instruction ?? "",
logDir: options.logDir
};
return this.launchExecutor(normalizedExecutor, launchOptions);
},
async createKey(payload) {
const custom = isCustomPayload(payload);
let providerKind = "builtin";
let providerId = String(payload?.providerId ?? "").trim();
let insertName = String(payload?.name ?? "").trim();
let apiKey = String(payload?.apiKey ?? "").trim();
let defaultModel = String(payload?.defaultModel ?? "").trim();
let apiUrl = null;
let basePath = null;
let engine = "openai";
let relayProvider = null;
let modelsJson = null;
let meta = catalogItem(providerId);
if (custom) {
const validated = validateCustomPayload(payload);
if (!validated.ok) return validated;
providerKind = "custom";
providerId = CUSTOM_PROVIDER_ID;
meta = catalogById[CUSTOM_PROVIDER_ID];
insertName = validated.value.name;
apiKey = validated.value.apiKey;
apiUrl = validated.value.apiUrl;
basePath = validated.value.basePath;
engine = validated.value.engine;
relayProvider = validated.value.relayProvider;
defaultModel = validated.value.defaultModel;
modelsJson = JSON.stringify(validated.value.models);
} else if (providerId === "custom_qwen") {
if (!meta) return { ok: false, message: "\u4E0D\u652F\u6301\u7684 provider" };
if (!insertName) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" };
if (!apiKey) return { ok: false, message: "\u8BF7\u586B\u5199 API Key" };
providerKind = "custom";
apiUrl = meta.apiUrl ?? "https://dashscope.aliyuncs.com/compatible-mode/v1";
const qwenModels = meta.models;
defaultModel = defaultModel || meta.defaultModel || qwenModels[0];
if (!qwenModels.includes(defaultModel)) defaultModel = qwenModels[0];
modelsJson = JSON.stringify(qwenModels);
} else {
if (!meta) return { ok: false, message: "\u4E0D\u652F\u6301\u7684 provider" };
if (!insertName) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" };
if (!apiKey) return { ok: false, message: "\u8BF7\u586B\u5199 API Key" };
defaultModel = defaultModel || meta.defaultModel;
if (!meta.models.includes(defaultModel)) {
return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6A21\u578B" };
}
}
const [existing] = await pool2.query(
"SELECT id FROM h5_llm_provider_keys WHERE name = ? LIMIT 1",
[insertName]
);
if (existing.length > 0) return { ok: false, message: "\u914D\u7F6E\u540D\u79F0\u5DF2\u5B58\u5728" };
const [countRows] = await pool2.query("SELECT COUNT(*) AS total FROM h5_llm_provider_keys");
const shouldSelect = Number(countRows[0]?.total ?? 0) === 0;
const encrypted = encryptSecret(apiKey, encryptionKey);
const now = Date.now();
const id = crypto3.randomUUID();
if (shouldSelect) await clearSelected();
await pool2.query(
`INSERT INTO h5_llm_provider_keys
(id, provider_id, provider_kind, api_url, base_path, models_json, goosed_provider_id, engine, relay_provider,
name, api_key_ciphertext, api_key_iv, api_key_tag, default_model, status, is_selected, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`,
[
id,
providerId,
providerKind,
apiUrl,
basePath,
modelsJson,
engine,
relayProvider,
insertName,
encrypted.ciphertext,
encrypted.iv,
encrypted.tag,
defaultModel,
shouldSelect ? 1 : 0,
now,
now
]
);
let row = await getRowById(id);
if (shouldSelect) {
await syncRow(row);
row = await getRowById(id);
}
return { ok: true, key: rowToPublic(row, meta, apiKey) };
},
async updateKey(id, payload) {
const row = await getRowById(id);
if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" };
const providerKind = row.provider_kind ?? "builtin";
const nextName = payload?.name !== void 0 ? String(payload.name).trim() : row.name;
const nextApiKey = payload?.apiKey !== void 0 ? String(payload.apiKey).trim() : null;
const nextStatus = payload?.status === "disabled" || payload?.status === "active" ? payload.status : row.status;
if (!nextName) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" };
const [existing] = await pool2.query(
"SELECT id FROM h5_llm_provider_keys WHERE name = ? AND id <> ? LIMIT 1",
[nextName, id]
);
if (existing.length > 0) return { ok: false, message: "\u914D\u7F6E\u540D\u79F0\u5DF2\u5B58\u5728" };
if (row.is_selected && nextStatus === "disabled") {
return { ok: false, message: "\u5F53\u524D\u542F\u7528\u7684\u914D\u7F6E\u4E0D\u80FD\u7981\u7528\uFF0C\u8BF7\u5148\u5207\u6362\u5230\u5176\u4ED6\u914D\u7F6E" };
}
let nextModel = row.default_model;
let nextApiUrl = row.api_url;
let nextBasePath = row.base_path;
let nextEngine = row.engine ?? "openai";
let nextRelayProvider = row.relay_provider;
let nextModelsJson = row.models_json;
if (providerKind === "custom") {
const models = payload?.models !== void 0 ? parseModelList(payload.models) : parseModelsJson(row.models_json);
nextModel = payload?.defaultModel !== void 0 ? String(payload.defaultModel).trim() : row.default_model;
nextApiUrl = payload?.apiUrl !== void 0 ? normalizeApiUrl(payload.apiUrl) : row.api_url;
nextBasePath = payload?.basePath !== void 0 ? String(payload.basePath).trim() || null : row.base_path;
nextEngine = payload?.engine !== void 0 ? String(payload.engine).trim() || "openai" : row.engine;
nextRelayProvider = payload?.relayProvider !== void 0 ? String(payload.relayProvider).trim() || null : row.relay_provider;
if (models.length === 0) return { ok: false, message: "\u8BF7\u81F3\u5C11\u4FDD\u7559\u4E00\u4E2A\u6A21\u578B" };
if (!models.includes(nextModel)) {
return { ok: false, message: "\u9ED8\u8BA4\u6A21\u578B\u5FC5\u987B\u5728\u6A21\u578B\u5217\u8868\u4E2D" };
}
if (!nextApiUrl) return { ok: false, message: "\u8BF7\u586B\u5199 API \u5730\u5740" };
nextModelsJson = JSON.stringify(models);
} else {
const meta = catalogItem(row.provider_id);
if (!meta) return { ok: false, message: "\u4E0D\u652F\u6301\u7684 provider" };
nextModel = payload?.defaultModel !== void 0 ? String(payload.defaultModel).trim() : row.default_model;
if (!meta.models.includes(nextModel)) {
return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6A21\u578B" };
}
}
const encrypted = nextApiKey ? encryptSecret(nextApiKey, encryptionKey) : {
ciphertext: row.api_key_ciphertext,
iv: row.api_key_iv,
tag: row.api_key_tag
};
const now = Date.now();
await pool2.query(
`UPDATE h5_llm_provider_keys
SET name = ?, api_url = ?, base_path = ?, models_json = ?, engine = ?, relay_provider = ?,
api_key_ciphertext = ?, api_key_iv = ?, api_key_tag = ?,
default_model = ?, status = ?, updated_at = ?
WHERE id = ?`,
[
nextName,
nextApiUrl,
nextBasePath,
nextModelsJson,
nextEngine,
nextRelayProvider,
encrypted.ciphertext,
encrypted.iv,
encrypted.tag,
nextModel,
nextStatus,
now,
id
]
);
if (row.is_selected) {
await syncRow(await getRowById(id));
}
const updated = await getRowById(id);
return {
ok: true,
key: rowToPublic(updated, catalogItem(updated.provider_id), nextApiKey || decryptRow(row))
};
},
async selectKey(id) {
const row = await getRowById(id);
if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" };
if (row.status !== "active") {
return { ok: false, message: "\u5DF2\u7981\u7528\u7684\u914D\u7F6E\u4E0D\u80FD\u542F\u7528" };
}
await clearSelected();
const now = Date.now();
await pool2.query(
"UPDATE h5_llm_provider_keys SET is_selected = 1, updated_at = ? WHERE id = ?",
[now, id]
);
await syncRow(row);
const updated = await getRowById(id);
return {
ok: true,
key: rowToPublic(updated, catalogItem(updated.provider_id), decryptRow(updated))
};
},
async deleteKey(id) {
const row = await getRowById(id);
if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" };
if (row.is_selected) {
return { ok: false, message: "\u5F53\u524D\u542F\u7528\u7684\u914D\u7F6E\u4E0D\u80FD\u5220\u9664\uFF0C\u8BF7\u5148\u5207\u6362\u5230\u5176\u4ED6\u914D\u7F6E" };
}
if ((row.provider_kind ?? "builtin") === "custom" && row.goosed_provider_id) {
await removeCustomProviderOnGoosed(
apiTarget,
apiSecret,
row.goosed_provider_id,
apiFetchImpl
);
}
await pool2.query("DELETE FROM h5_llm_provider_keys WHERE id = ?", [id]);
return { ok: true };
},
async syncSelectedToGoosed() {
const targets = syncTargets.length ? syncTargets : [apiTarget].filter(Boolean);
let lastResolved = null;
for (const target of targets) {
const targetFetch = (url, init) => {
const pathname = `${url.pathname}${url.search}`;
return goosedApiFetch(target, apiSecret, pathname, init, apiFetchImpl);
};
let resolved = await resolveExecutorProvider("goose", "default", targetFetch);
if (!resolved.ok) {
resolved = await resolveSelectedProvider(targetFetch);
}
if (!resolved.ok) {
return { ok: false, synced: false, target, message: resolved.message };
}
lastResolved = resolved;
}
return {
ok: true,
synced: true,
targets,
source: lastResolved.source,
providerId: lastResolved.providerId,
model: lastResolved.model
};
},
async applyBestProviderForSession(sessionId, fetchImpl = apiFetchImpl) {
let resolved = await resolveExecutorProvider("goose", "default", fetchImpl);
if (!resolved.ok) {
resolved = await resolveSelectedProvider(fetchImpl);
}
if (!resolved.ok) {
return resolved;
}
const sessionGoosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl);
await updateSessionProvider(sessionGoosedApi, sessionId, resolved.providerId, resolved.model);
return resolved;
},
/** Switch session to local Ollama (credits exhausted or relay 500 payload limit). */
async applyLocalFallbackForSession(sessionId, fetchImpl = apiFetchImpl) {
const resolved = await resolveLocalFallbackProvider(fetchImpl);
if (!resolved.ok) {
return resolved;
}
const sessionGoosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl);
await updateSessionProvider(sessionGoosedApi, sessionId, resolved.providerId, resolved.model);
return resolved;
},
async hasVisionKey() {
if (visionKeyConfiguredCache !== null) return visionKeyConfiguredCache;
const row = await getVisionRow();
visionKeyConfiguredCache = row !== null;
return visionKeyConfiguredCache;
},
async getVisionSettings() {
const row = await getVisionRow();
if (!row) {
return { keyId: null, keyName: null, providerLabel: null, visionModel: null, availableModels: [] };
}
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
return {
keyId: publicRow.id,
keyName: publicRow.name,
providerLabel: publicRow.providerLabel,
visionModel: publicRow.defaultModel,
availableModels: publicRow.models
};
},
async setVisionKey(keyId, model) {
const nextModel = String(model ?? "").trim();
if (!nextModel) return { ok: false, message: "\u8BF7\u9009\u62E9\u89C6\u89C9\u6A21\u578B" };
const row = await getRowById(keyId);
if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" };
if (row.status !== "active") return { ok: false, message: "\u8BE5\u914D\u7F6E\u5DF2\u7981\u7528" };
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
if (!publicRow.models.includes(nextModel)) {
return { ok: false, message: "\u6A21\u578B\u4E0D\u5728\u8BE5\u914D\u7F6E\u652F\u6301\u5217\u8868\u4E2D" };
}
await clearVisionSelected();
const now = Date.now();
await pool2.query(
"UPDATE h5_llm_provider_keys SET is_vision_selected = 1, default_model = ?, updated_at = ? WHERE id = ?",
[nextModel, now, keyId]
);
visionKeyConfiguredCache = true;
return { ok: true, vision: await this.getVisionSettings() };
},
async clearVisionKey() {
await clearVisionSelected();
visionKeyConfiguredCache = false;
return { ok: true };
},
async applyVisionProviderForSession(sessionId, fetchImpl = apiFetchImpl) {
const row = await getVisionRow();
if (!row) return { ok: false, message: "\u672A\u914D\u7F6E\u56FE\u7247\u4EFB\u52A1\u6A21\u578B" };
const sessionGoosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl);
let goosedProviderId;
try {
goosedProviderId = await syncRow(row, fetchImpl);
if (!goosedProviderId) {
goosedProviderId = profileFromRow(row, decryptRow).goosedProviderId ?? row.provider_id;
}
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : "\u540C\u6B65\u89C6\u89C9\u6A21\u578B\u5931\u8D25" };
}
await updateSessionProvider(sessionGoosedApi, sessionId, goosedProviderId, row.default_model);
return { ok: true, providerId: goosedProviderId, model: row.default_model, source: "vision" };
},
// Calls the vision provider directly (not through Goose) to analyze images.
// Returns the model's text description, or null on failure.
async analyzeImagesWithVision(imageItems, userText) {
const row = await getVisionRow();
if (!row) return null;
const apiUrl = String(row.api_url ?? "").trim();
if (!apiUrl) return null;
const apiKey = decryptRow(row);
if (!apiKey) return null;
const model = String(row.default_model ?? "qwen-vl-max").trim();
const content = [
...imageItems.map((item) => ({
type: "image_url",
image_url: { url: `data:${item.mimeType};base64,${item.data}` }
})),
{ type: "text", text: String(userText || "\u8BF7\u63CF\u8FF0\u8FD9\u5F20\u56FE\u7247\u7684\u5185\u5BB9").trim() }
];
try {
const resp = await undiciFetch(`${apiUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`
},
body: JSON.stringify({
model,
messages: [{ role: "user", content }],
max_tokens: 1200
}),
signal: AbortSignal.timeout(3e4)
});
if (!resp.ok) return null;
const json = await resp.json();
return json?.choices?.[0]?.message?.content ?? null;
} catch {
return null;
}
},
async getGlobalSettings() {
const row = await getSelectedRow();
if (!row) {
return {
keyId: null,
keyName: null,
providerLabel: null,
globalModel: null,
availableModels: []
};
}
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
return {
keyId: publicRow.id,
keyName: publicRow.name,
providerLabel: publicRow.providerLabel,
globalModel: publicRow.defaultModel,
availableModels: publicRow.models
};
},
async setGlobalModel(model) {
const nextModel = String(model ?? "").trim();
if (!nextModel) return { ok: false, message: "\u8BF7\u9009\u62E9\u5168\u5C40\u6A21\u578B" };
const row = await getSelectedRow();
if (!row) return { ok: false, message: "\u8BF7\u5148\u542F\u7528\u4E00\u4E2A LLM \u914D\u7F6E" };
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
if (!publicRow.models.includes(nextModel)) {
return { ok: false, message: "\u6A21\u578B\u4E0D\u5728\u5F53\u524D Provider \u652F\u6301\u5217\u8868\u4E2D" };
}
await pool2.query(
"UPDATE h5_llm_provider_keys SET default_model = ?, updated_at = ? WHERE id = ?",
[nextModel, Date.now(), row.id]
);
await syncRow(await getRowById(row.id));
return { ok: true, global: await this.getGlobalSettings() };
},
async testDraft(payload) {
const custom = isCustomPayload(payload);
if (!custom) {
return { ok: false, message: "\u8054\u901A\u6D4B\u8BD5\u76EE\u524D\u652F\u6301\u81EA\u5B9A\u4E49 OpenAI \u517C\u5BB9\u914D\u7F6E" };
}
const validated = validateCustomPayload(payload);
if (!validated.ok) return validated;
const model = String(payload?.testModel ?? payload?.defaultModel ?? validated.value.defaultModel).trim();
if (!validated.value.models.includes(model)) {
return { ok: false, message: "\u6D4B\u8BD5\u6A21\u578B\u4E0D\u5728\u6A21\u578B\u5217\u8868\u4E2D" };
}
return testRelayConnection(
{
apiUrl: validated.value.apiUrl,
apiKey: validated.value.apiKey,
model,
relayProvider: validated.value.relayProvider
},
apiFetchImpl
);
},
async testKey(id, testModel) {
const row = await getRowById(id);
if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" };
const model = String(testModel ?? row.default_model).trim();
const providerKind = row.provider_kind ?? "builtin";
const meta = catalogItem(row.provider_id);
const models = providerKind === "custom" ? parseModelsJson(row.models_json) : meta?.models ?? [];
if (!models.includes(model)) {
return { ok: false, message: "\u6D4B\u8BD5\u6A21\u578B\u4E0D\u5728\u914D\u7F6E\u5217\u8868\u4E2D" };
}
if (providerKind !== "custom") {
const apiUrl = BUILTIN_PROVIDER_TEST_URLS[row.provider_id];
if (!apiUrl) {
return { ok: false, message: "\u8BE5\u5185\u7F6E Provider \u6682\u4E0D\u652F\u6301\u8054\u901A\u6D4B\u8BD5" };
}
return testRelayConnection(
{
apiUrl,
apiKey: decryptRow(row),
model,
relayProvider: null
},
apiFetchImpl
);
}
return testRelayConnection(
{
apiUrl: row.api_url,
apiKey: decryptRow(row),
model,
relayProvider: row.relay_provider
},
apiFetchImpl
);
},
async ensureBootstrapRelay() {
const [existing] = await pool2.query(
"SELECT id FROM h5_llm_provider_keys WHERE name = ? LIMIT 1",
[RELAY_BOOTSTRAP.name]
);
if (existing.length > 0) {
return { ok: true, created: false, keyId: existing[0].id };
}
const result = await this.createKey({
providerId: CUSTOM_PROVIDER_ID,
name: RELAY_BOOTSTRAP.name,
apiKey: RELAY_BOOTSTRAP.apiKey,
apiUrl: RELAY_BOOTSTRAP.apiUrl,
models: RELAY_BOOTSTRAP.models,
defaultModel: RELAY_BOOTSTRAP.defaultModel,
relayProvider: RELAY_BOOTSTRAP.relayProvider
});
if (!result.ok) return result;
if (result.key && !result.key.isSelected) {
await this.selectKey(result.key.id);
}
return { ok: true, created: true, key: result.key };
}
};
}
// tkmind-proxy.mjs
import fs5 from "node:fs";
import path8 from "node:path";
import { Readable, Transform as Transform2, Writable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { Agent as Agent2, fetch as undiciFetch2 } from "undici";
// sse-billing.mjs
import { Transform } from "node:stream";
function parseSseChunk(chunk) {
const events = [];
const blocks = chunk.split("\n\n");
for (const block of blocks) {
if (!block.trim()) continue;
let data;
for (const line of block.split("\n")) {
if (line.startsWith("data:")) {
data = line.slice(5).trim();
}
}
if (!data) continue;
try {
events.push(JSON.parse(data));
} catch {
}
}
return events;
}
function finishBillingKey(event) {
const requestId = event.request_id ?? event.chat_request_id;
if (requestId) return String(requestId);
const state = event.token_state ?? {};
const input = state.accumulatedInputTokens ?? state.accumulated_input_tokens ?? state.inputTokens ?? 0;
const output = state.accumulatedOutputTokens ?? state.accumulated_output_tokens ?? state.outputTokens ?? 0;
return `${input}:${output}`;
}
function createSseBillingTransform({ onFinish }) {
let buffer = "";
const billedFinishKeys = /* @__PURE__ */ new Set();
const billFinishOnce = (event) => {
if (event?.type !== "Finish" || !event.token_state) return;
const key = finishBillingKey(event);
if (billedFinishKeys.has(key)) return;
billedFinishKeys.add(key);
void onFinish(event).catch(() => {
});
};
return new Transform({
transform(chunk, _encoding, callback) {
buffer += chunk.toString("utf8");
const parts = buffer.split("\n\n");
buffer = parts.pop() ?? "";
for (const block of parts) {
if (!block.trim()) continue;
let dataLine;
for (const line of block.split("\n")) {
if (line.startsWith("data:")) dataLine = line.slice(5).trim();
}
if (!dataLine) continue;
try {
billFinishOnce(JSON.parse(dataLine));
} catch {
}
}
callback(null, chunk);
},
flush(callback) {
if (buffer.trim()) {
for (const event of parseSseChunk(`${buffer}
`)) {
billFinishOnce(event);
}
}
callback();
}
});
}
function appendBalanceEvent(payload) {
const data = typeof payload === "number" ? { balanceCents: payload } : payload && typeof payload === "object" ? payload : { balanceCents: 0 };
return `event: balance
data: ${JSON.stringify(data)}
`;
}
// capabilities.mjs
import path4 from "node:path";
import { fileURLToPath as fileURLToPath2 } from "node:url";
// policies.mjs
var POLICY_CATALOG = [
{
key: "goose_mode",
label: "TKMind \u6A21\u5F0F",
description: "H5 \u7EC8\u7AEF\u7528\u6237\u4E0D\u5728\u804A\u5929\u91CC\u70B9\u300C\u5141\u8BB8/\u62D2\u7EDD\u300D\uFF1B\u53EF\u7528\u5DE5\u5177\u7531\u300C\u80FD\u529B\u6743\u9650\u300D\u51B3\u5B9A\u3002chat \u4E0D\u8C03\u7528\u5DE5\u5177\uFF0Cauto \u5728\u6388\u6743\u8303\u56F4\u5185\u81EA\u52A8\u6267\u884C\u3002",
type: "select",
options: [
{ value: "chat", label: "\u4EC5\u804A\u5929 (chat)" },
{ value: "auto", label: "\u81EA\u52A8\u6267\u884C (auto)" }
],
defaultValue: "chat",
category: "goose",
risk: "high"
},
{
key: "workspace_access",
label: "\u5DE5\u4F5C\u533A\u8BBF\u95EE",
description: "readonly \u65F6\u7981\u6B62 shell \u4E0E\u6587\u4EF6\u5199\u5165\uFF0C\u4EC5\u5141\u8BB8\u6D4F\u89C8\u4E0E\u5206\u6790",
type: "select",
options: [
{ value: "readwrite", label: "\u8BFB\u5199" },
{ value: "readonly", label: "\u53EA\u8BFB" }
],
defaultValue: "readwrite",
category: "workspace",
risk: "medium"
},
{
key: "network_egress",
label: "\u7F51\u7EDC\u51FA\u7AD9",
description: "deny \u65F6\u7981\u7528 shell\u3001\u7535\u8111\u63A7\u5236\u3001\u6C99\u7BB1\u811A\u672C\u7B49\u53EF\u80FD\u8BBF\u95EE\u7F51\u7EDC\u7684\u6269\u5C55",
type: "select",
options: [
{ value: "deny", label: "\u7981\u6B62\uFF08\u63A8\u8350\uFF09" },
{ value: "allow", label: "\u5141\u8BB8\uFF08\u9700\u540C\u65F6\u5F00\u542F\u5BF9\u5E94\u80FD\u529B\uFF09" }
],
defaultValue: "deny",
category: "network",
risk: "high"
},
{
key: "api_lockdown",
label: "API \u4EE3\u7406\u9501\u5B9A",
description: "\u5F00\u542F\u540E\u4EC5\u5141\u8BB8\u804A\u5929\u76F8\u5173 API\uFF0C\u62E6\u622A\u6539\u6A21\u5F0F\u3001\u52A0\u6269\u5C55\u3001\u5199\u914D\u7F6E\u7B49\u5371\u9669\u8BF7\u6C42",
type: "boolean",
defaultValue: true,
category: "proxy",
risk: "medium"
},
{
key: "code_delegate_executor",
label: "\u4EE3\u7801\u59D4\u6258\u6267\u884C\u5668",
description: "\u63A7\u5236 Goose \u5728\u591A\u6587\u4EF6\u7F16\u7801\u3001\u4FEE\u590D\u4E0E\u91CD\u6784\u4EFB\u52A1\u4E2D\u4F18\u5148\u59D4\u6258\u7ED9\u8C01\u3002auto \u7531 Goose \u7ED3\u5408\u5F53\u524D\u53EF\u7528\u6269\u5C55\u81EA\u884C\u5224\u65AD\uFF1Baider / openhands \u5219\u4F18\u5148\u4F7F\u7528\u6307\u5B9A\u6267\u884C\u5668\u3002",
type: "select",
options: [
{ value: "auto", label: "\u81EA\u52A8\u9009\u62E9 (auto)" },
{ value: "aider", label: "\u4F18\u5148 Aider" },
{ value: "openhands", label: "\u4F18\u5148 OpenHands" }
],
defaultValue: "auto",
category: "routing",
risk: "medium"
},
{
key: "code_task_routing",
label: "\u4EE3\u7801\u4EFB\u52A1\u8DEF\u7531\u7B56\u7565",
description: "\u5B9A\u4E49\u4EE3\u7801\u4EFB\u52A1\u5982\u4F55\u5728 Aider \u4E0E OpenHands \u4E4B\u95F4\u5206\u6D41\u3002balanced \u7531 Goose \u6309\u4EFB\u52A1\u590D\u6742\u5EA6\u5224\u65AD\uFF1Bsplit \u5EFA\u8BAE\u5C0F\u6539\u52A8\u8D70 Aider\u3001\u590D\u6742\u4EFB\u52A1\u8D70 OpenHands\uFF1Bforce_* \u5219\u5F3A\u5236\u4F18\u5148\u5355\u4E00\u8DEF\u5F84\u3002",
type: "select",
options: [
{ value: "balanced", label: "\u5E73\u8861\u8DEF\u7531 (balanced)" },
{ value: "split", label: "\u5C0F\u6539\u52A8 Aider\uFF0C\u590D\u6742\u4EFB\u52A1 OpenHands" },
{ value: "force_aider", label: "\u5C3D\u91CF\u7EDF\u4E00\u8D70 Aider" },
{ value: "force_openhands", label: "\u5C3D\u91CF\u7EDF\u4E00\u8D70 OpenHands" }
],
defaultValue: "balanced",
category: "routing",
risk: "medium"
}
];
var DEFAULT_USER_POLICIES = Object.fromEntries(
POLICY_CATALOG.map((item) => [item.key, item.defaultValue])
);
var POLICY_KEYS = new Set(POLICY_CATALOG.map((item) => item.key));
var GOOSE_MODES = /* @__PURE__ */ new Set(["auto", "approve", "smart_approve", "chat"]);
var WORKSPACE_ACCESS = /* @__PURE__ */ new Set(["readwrite", "readonly"]);
var NETWORK_EGRESS = /* @__PURE__ */ new Set(["allow", "deny"]);
var CODE_DELEGATE_EXECUTORS = /* @__PURE__ */ new Set(["auto", "aider", "openhands"]);
var CODE_TASK_ROUTINGS = /* @__PURE__ */ new Set(["balanced", "split", "force_aider", "force_openhands"]);
function policyKeys() {
return [...POLICY_KEYS];
}
function isValidPolicyKey(key) {
return POLICY_KEYS.has(key);
}
function normalizePolicyPatch(patch) {
const normalized = {};
for (const [key, raw] of Object.entries(patch ?? {})) {
if (!isValidPolicyKey(key)) continue;
const def = POLICY_CATALOG.find((item) => item.key === key);
if (!def) continue;
if (def.type === "boolean") {
normalized[key] = raw === true || raw === "true" || raw === 1 || raw === "1";
continue;
}
const value = String(raw ?? "").trim();
if (def.key === "goose_mode" && GOOSE_MODES.has(value)) normalized[key] = value;
if (def.key === "workspace_access" && WORKSPACE_ACCESS.has(value)) normalized[key] = value;
if (def.key === "network_egress" && NETWORK_EGRESS.has(value)) normalized[key] = value;
if (def.key === "code_delegate_executor" && CODE_DELEGATE_EXECUTORS.has(value)) {
normalized[key] = value;
}
if (def.key === "code_task_routing" && CODE_TASK_ROUTINGS.has(value)) {
normalized[key] = value;
}
}
return normalized;
}
function resolvePolicies(rolePolicies, userOverrides) {
const resolved = { ...DEFAULT_USER_POLICIES };
for (const [key, value] of Object.entries(rolePolicies ?? {})) {
if (isValidPolicyKey(key)) resolved[key] = value;
}
for (const [key, value] of Object.entries(userOverrides ?? {})) {
if (isValidPolicyKey(key)) resolved[key] = value;
}
if (resolved.goose_mode === "approve" || resolved.goose_mode === "smart_approve") {
resolved.goose_mode = "auto";
}
return resolved;
}
function hasExecutableTools(capabilities) {
return Boolean(
capabilities?.static_publish || capabilities?.shell || capabilities?.filesystem || capabilities?.code_browse || capabilities?.subagent || capabilities?.code_sandbox || capabilities?.computer || capabilities?.charts || capabilities?.aider || capabilities?.openhands || capabilities?.apps || capabilities?.todo || capabilities?.skills || capabilities?.chat_recall
);
}
function resolveAgentGooseMode(capabilities, policies) {
const requested = policies?.goose_mode ?? DEFAULT_USER_POLICIES.goose_mode;
if (!hasExecutableTools(capabilities)) {
return "chat";
}
if (requested === "chat" || requested === "approve" || requested === "smart_approve") {
return "auto";
}
return "auto";
}
function applyPoliciesToCapabilities(capabilities, policies) {
const effective = { ...capabilities };
if (policies.workspace_access === "readonly") {
effective.shell = false;
effective.filesystem = false;
effective.static_publish = false;
effective.aider = false;
effective.openhands = false;
effective.code_sandbox = false;
effective.image_read = false;
}
if (policies.network_egress === "deny") {
if (!effective.static_publish) {
effective.shell = false;
}
effective.computer = false;
effective.code_sandbox = false;
}
return effective;
}
var USER_API_ALLOWLIST = [
{ method: "GET", pattern: /^\/status$/ },
{ method: "POST", pattern: /^\/agent\/start$/ },
{ method: "POST", pattern: /^\/agent\/resume$/ },
{ method: "POST", pattern: /^\/agent\/runs$/ },
{ method: "GET", pattern: /^\/agent\/runs\/[^/]+$/ },
{ method: "GET", pattern: /^\/agent\/runs\/[^/]+\/events$/ },
{ method: "GET", pattern: /^\/sessions$/ },
{ method: "GET", pattern: /^\/sessions\/[^/]+$/ },
{ method: "DELETE", pattern: /^\/sessions\/[^/]+$/ },
{ method: "GET", pattern: /^\/sessions\/[^/]+\/events$/ },
{ method: "POST", pattern: /^\/sessions\/[^/]+\/reply$/ },
{ method: "POST", pattern: /^\/sessions\/[^/]+\/cancel$/ },
{ method: "POST", pattern: /^\/action-required\/tool-confirmation$/ },
{ method: "POST", pattern: /^\/agent\/update_provider$/ },
{ method: "POST", pattern: /^\/config\/read$/ },
{ method: "POST", pattern: /^\/agent\/harness_bootstrap$/ },
{ method: "POST", pattern: /^\/agent\/harness_remember$/ }
];
function isNativeH5ApiPath(pathname) {
const path13 = String(pathname ?? "");
return path13.startsWith("/mindspace/") || path13.startsWith("/plaza/");
}
function evaluateProxyRequest(method, pathname, policies, { unrestricted = false } = {}) {
if (unrestricted) return { allowed: true };
if (!policies.api_lockdown) return { allowed: true };
if (isNativeH5ApiPath(pathname)) {
return {
allowed: false,
reason: `H5 \u672C\u5730\u63A5\u53E3\u4E0D\u5E94\u8D70 Agent \u4EE3\u7406\uFF1A${method.toUpperCase()} ${pathname}`
};
}
const upper = method.toUpperCase();
const allowed = USER_API_ALLOWLIST.some(
(rule) => rule.method === upper && rule.pattern.test(pathname)
);
if (allowed) return { allowed: true };
return {
allowed: false,
reason: `\u7B56\u7565\u5DF2\u9501\u5B9A API\uFF1A${upper} ${pathname}`
};
}
// capabilities.mjs
function resolveSandboxMcpServerPath(overridePath) {
const normalized = String(overridePath ?? "").trim();
if (normalized) return normalized;
return path4.join(path4.dirname(fileURLToPath2(import.meta.url)), "mindspace-sandbox-mcp.mjs");
}
function resolveSandboxMcpNodeExecPath(overridePath) {
const normalized = String(overridePath ?? "").trim();
if (normalized) return normalized;
return process.execPath;
}
var CAPABILITY_CATALOG = [
{
key: "shell",
label: "Shell \u547D\u4EE4",
description: "\u6267\u884C bash/shell \u547D\u4EE4\uFF08developer.shell\uFF09",
risk: "high",
category: "developer"
},
{
key: "static_publish",
label: "\u7528\u6237\u6C99\u7BB1\u76EE\u5F55",
description: "\u5728 MindSpace/<\u7528\u6237ID>/ \u5185\u53EF\u4F7F\u7528 sandbox-fs \u7684 write_file/edit_file/read_file/create_dir\uFF08\u4EE5\u53CA\u6309\u6743\u9650\u5F00\u653E\u7684 list_dir\uFF1B\u4E0D\u53EF\u8D8A\u51FA\u8BE5\u76EE\u5F55\uFF09",
risk: "medium",
category: "publisher"
},
{
key: "private_data_space",
label: "\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4",
description: "\u4E3A\u7528\u6237\u63D0\u4F9B\u552F\u4E00\u7684\u79C1\u6709 SQLite \u6570\u636E\u7A7A\u95F4\uFF0C\u4F9B Agent \u521B\u5EFA\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u7B49\u79C1\u6709\u7ED3\u6784\u5316\u6570\u636E\u8868",
risk: "medium",
category: "publisher"
},
{
key: "filesystem",
label: "\u6587\u4EF6\u8BFB\u5199\uFF08\u5168\u76EE\u5F55\uFF09",
description: "\u5728\u5DE5\u4F5C\u533A\u5185\u4EFB\u610F\u8BFB\u5199\u6587\u4EF6\uFF08developer.write / edit\uFF09\uFF0C\u4EC5\u9AD8\u7EA7\u7528\u6237",
risk: "high",
category: "developer"
},
{
key: "code_browse",
label: "\u4EE3\u7801\u6D4F\u89C8",
description: "\u76EE\u5F55\u6811\u3001\u4EE3\u7801\u7ED3\u6784\u5206\u6790\uFF08developer.tree\u3001analyze\uFF09",
risk: "low",
category: "developer"
},
{
key: "image_read",
label: "\u56FE\u7247\u8BFB\u53D6",
description: "\u8BFB\u53D6\u672C\u5730\u6216\u7F51\u7EDC\u56FE\u7247\uFF08developer.read_image\uFF09",
risk: "low",
category: "developer"
},
{
key: "skills",
label: "\u6280\u80FD\u52A0\u8F7D",
description: "\u52A0\u8F7D\u6280\u80FD\u4E0E\u77E5\u8BC6\uFF08skills / summon.load\uFF09",
risk: "low",
category: "knowledge"
},
{
key: "subagent",
label: "\u5B50\u4EFB\u52A1\u6D3E\u53D1",
description: "delegate \u5B50 Agent \u6267\u884C\u590D\u6742\u4EFB\u52A1\uFF08summon\uFF09",
risk: "high",
category: "agent"
},
{
key: "code_sandbox",
label: "\u6C99\u7BB1\u811A\u672C",
description: "execute_typescript \u5728\u6C99\u7BB1\u4E2D\u6279\u91CF\u8C03\u7528\u5DE5\u5177\uFF08code_execution\uFF09",
risk: "high",
category: "agent"
},
{
key: "chat_recall",
label: "\u5BF9\u8BDD\u56DE\u6EAF",
description: "\u68C0\u7D22\u5386\u53F2\u4F1A\u8BDD\uFF08chatrecall\uFF09",
risk: "low",
category: "memory"
},
{
key: "context_memory",
label: "\u9879\u76EE\u8BB0\u5FC6",
description: "\u4F1A\u8BDD\u7EA7\u9879\u76EE\u8BB0\u5FC6\u6CE8\u5165\uFF08projectmemory\uFF0CH5 \u5F15\u5BFC\u7528\uFF09",
risk: "low",
category: "memory"
},
{
key: "memory_store",
label: "\u957F\u671F\u8BB0\u5FC6",
description: "\u8BB0\u4F4F/\u68C0\u7D22\u7528\u6237\u504F\u597D\uFF08memory \u6269\u5C55\uFF09",
risk: "medium",
category: "memory"
},
{
key: "extension_admin",
label: "\u6269\u5C55\u7BA1\u7406",
description: "\u641C\u7D22\u3001\u542F\u7528\u3001\u7981\u7528\u6269\u5C55\uFF08extensionmanager\uFF09",
risk: "high",
category: "admin"
},
{
key: "apps",
label: "\u5E94\u7528\u7BA1\u7406",
description: "\u521B\u5EFA\u4E0E\u7BA1\u7406 TKMind \u5E94\u7528\uFF08apps\uFF09",
risk: "medium",
category: "agent"
},
{
key: "todo",
label: "\u5F85\u529E\u4E8B\u9879",
description: "\u4EFB\u52A1\u5217\u8868\u8DDF\u8E2A\uFF08todo\uFF09",
risk: "low",
category: "productivity"
},
{
key: "computer",
label: "\u7535\u8111\u63A7\u5236",
description: "\u81EA\u52A8\u5316\u811A\u672C\u3001\u7F51\u9875\u6293\u53D6\u3001\u6587\u6863\u5904\u7406\uFF08computercontroller\uFF09",
risk: "high",
category: "automation"
},
{
key: "charts",
label: "\u6570\u636E\u53EF\u89C6\u5316",
description: "\u56FE\u8868\u4E0E\u53EF\u89C6\u5316\uFF08autovisualiser\uFF09",
risk: "low",
category: "automation"
},
{
key: "aider",
label: "Aider \u7F16\u7801",
description: "\u591A\u6587\u4EF6\u7F16\u7801\u59D4\u6258\uFF08aider\uFF09",
risk: "high",
category: "developer"
},
{
key: "openhands",
label: "OpenHands \u7F16\u7801",
description: "\u590D\u6742\u591A\u6587\u4EF6\u7F16\u7801\u4E0E\u4ED3\u5E93\u7EA7\u4EFB\u52A1\u59D4\u6258\uFF08openhands\uFF09",
risk: "high",
category: "developer"
}
];
var USER_NON_GRANTABLE_CAPABILITIES = /* @__PURE__ */ new Set(["extension_admin"]);
function clampUserCapabilities(capabilities) {
const clamped = { ...capabilities };
for (const key of USER_NON_GRANTABLE_CAPABILITIES) {
clamped[key] = false;
}
return clamped;
}
var DEFAULT_USER_CAPABILITIES = Object.fromEntries(
CAPABILITY_CATALOG.map(({ key }) => {
const defaults = {
shell: false,
static_publish: false,
private_data_space: true,
filesystem: false,
code_browse: false,
image_read: true,
skills: true,
subagent: false,
code_sandbox: false,
chat_recall: true,
context_memory: true,
memory_store: true,
extension_admin: false,
apps: false,
todo: false,
computer: false,
charts: false,
aider: false,
openhands: false
};
return [key, defaults[key] ?? false];
})
);
var CATALOG_KEYS = new Set(CAPABILITY_CATALOG.map((item) => item.key));
function catalogKeys() {
return [...CATALOG_KEYS];
}
function isValidCapabilityKey(key) {
return CATALOG_KEYS.has(key);
}
function makeExtension(type, name, tools = []) {
return {
type,
name,
description: "",
display_name: name,
bundled: true,
available_tools: tools
};
}
function sandboxDeveloperTools(capabilities) {
const tools = ["write", "edit"];
if (capabilities.shell) {
tools.push("shell", "tree");
} else if (capabilities.code_browse) {
tools.push("tree");
}
if (capabilities.image_read) tools.push("read_image");
return tools;
}
function sandboxMcpTools(capabilities) {
const tools = [];
if (capabilities.static_publish) {
tools.push("read_file", "write_file", "edit_file", "create_dir", "generate_long_image");
if (capabilities.shell || capabilities.code_browse) tools.push("list_dir");
}
if (capabilities.private_data_space) {
tools.push(
"private_data_info",
"private_data_schema",
"private_data_query",
"private_data_execute",
"schedule_create_item",
"schedule_create_reminder",
"schedule_list_items"
);
}
return tools;
}
function developerToolsFromPolicy(sessionPolicy) {
const developer = sessionPolicy?.extensionOverrides?.find((ext) => ext.name === "developer");
const sandboxFs = sessionPolicy?.extensionOverrides?.find((ext) => ext.name === "sandbox-fs");
return (sandboxFs ?? developer)?.available_tools ?? [];
}
function mergeDeveloperTools(capabilities) {
const tools = [];
if (capabilities.shell) tools.push("shell");
if (capabilities.filesystem) tools.push("write", "edit");
if (capabilities.code_browse) tools.push("tree");
if (capabilities.image_read) tools.push("read_image");
return tools;
}
function sandboxMcpEnvs(sandboxMcp, mcpTools) {
const envs = {
SANDBOX_ROOT: sandboxMcp.sandboxRoot,
ALLOWED_TOOLS: mcpTools.join(",")
};
if (sandboxMcp.userId) envs.PRIVATE_DATA_USER_ID = sandboxMcp.userId;
for (const key of [
"DATABASE_URL",
"MYSQL_HOST",
"MYSQL_PORT",
"MYSQL_USER",
"MYSQL_PASSWORD",
"MYSQL_DATABASE",
"PRIVATE_DATA_MAX_BYTES"
]) {
if (process.env[key]) envs[key] = process.env[key];
}
return envs;
}
function buildAgentExtensionPolicy(capabilities, { unrestricted = false, policies = null, sandboxMcp = null, toolMode = "chat" } = {}) {
if (unrestricted) {
return { extensionOverrides: null, enableContextMemory: true, gooseMode: "auto" };
}
const extensions = [];
if (capabilities.static_publish || capabilities.private_data_space && sandboxMcp) {
if (sandboxMcp?.serverPath && sandboxMcp?.sandboxRoot) {
const mcpTools = sandboxMcpTools(capabilities);
if (mcpTools.length > 0) {
extensions.push({
type: "stdio",
name: "sandbox-fs",
description: "\u5DE5\u4F5C\u533A\u6C99\u7BB1\u6587\u4EF6\u7CFB\u7EDF\u4E0E\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u3002\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u662F\u5F53\u524D\u7528\u6237\u552F\u4E00\u7684 SQLite \u6570\u636E\u5E93\uFF0C\u9002\u5408\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u3001\u8C03\u7814\u6570\u636E\u548C\u5206\u6790\u4E2D\u95F4\u8868\uFF1B\u4E0D\u8981\u7528\u4E8E\u8D26\u53F7\u3001\u8BA1\u8D39\u3001\u6743\u9650\u3001\u5BA1\u8BA1\u3001\u516C\u5F00\u5E73\u53F0\u6570\u636E\u6216\u8DE8\u7528\u6237\u6570\u636E\u3002",
display_name: "sandbox-fs",
bundled: false,
cmd: resolveSandboxMcpNodeExecPath(sandboxMcp.nodeExecPath),
// sandboxRoot passed as argv[2] so it works even if goosed doesn't forward envs
args: [sandboxMcp.serverPath, sandboxMcp.sandboxRoot],
// envs (goosed field name) as belt-and-suspenders backup
envs: sandboxMcpEnvs(sandboxMcp, mcpTools),
available_tools: mcpTools
});
}
if (capabilities.image_read) {
extensions.push(makeExtension("platform", "developer", ["read_image"]));
}
} else if (capabilities.static_publish) {
const sandboxTools = sandboxDeveloperTools(capabilities);
if (sandboxTools.length > 0) {
extensions.push(makeExtension("platform", "developer", sandboxTools));
}
}
if (capabilities.static_publish) {
extensions.push(makeExtension("platform", "skills", []));
extensions.push(makeExtension("platform", "summon", ["load_skill"]));
extensions.push(makeExtension("platform", "projectmemory", []));
}
} else {
const developerTools = mergeDeveloperTools(capabilities);
if (developerTools.length > 0) {
extensions.push(makeExtension("platform", "developer", developerTools));
}
if (capabilities.code_browse) {
extensions.push(makeExtension("platform", "analyze", []));
}
if (capabilities.skills) {
extensions.push(makeExtension("platform", "skills", []));
}
if (capabilities.skills || capabilities.subagent) {
const summonTools = [];
if (capabilities.skills) summonTools.push("load", "load_skill");
if (capabilities.subagent) summonTools.push("delegate");
extensions.push(makeExtension("platform", "summon", summonTools));
}
}
const enableCodeExecutionExtension = /^(1|true|yes)$/i.test(
process.env.TKMIND_ENABLE_CODE_EXECUTION_EXTENSION ?? ""
);
if (capabilities.code_sandbox && enableCodeExecutionExtension) {
extensions.push(makeExtension("platform", "code_execution", []));
}
if (capabilities.chat_recall) {
extensions.push(makeExtension("platform", "chatrecall", []));
}
if (capabilities.context_memory) {
extensions.push(makeExtension("platform", "projectmemory", []));
}
if (capabilities.memory_store) {
extensions.push(makeExtension("builtin", "memory", []));
}
if (capabilities.extension_admin) {
extensions.push(makeExtension("platform", "extensionmanager", []));
}
if (capabilities.apps) {
extensions.push(makeExtension("platform", "apps", []));
}
if (capabilities.todo) {
extensions.push(makeExtension("platform", "todo", []));
}
if (capabilities.computer) {
extensions.push(makeExtension("builtin", "computercontroller", []));
}
if (capabilities.charts) {
extensions.push(makeExtension("builtin", "autovisualiser", []));
}
const codeToolMode = toolMode === "code" || toolMode === "code-task";
if (codeToolMode && capabilities.aider) {
extensions.push({
...makeExtension("platform", "aider", []),
timeout_ms: Number(process.env.MEMIND_AIDER_TIMEOUT_MS ?? 6e5),
metadata: { runtime_scope: "code_tool_task" }
});
}
if (codeToolMode && capabilities.openhands) {
extensions.push({
...makeExtension("platform", "openhands", []),
timeout_ms: Number(process.env.MEMIND_OPENHANDS_TIMEOUT_MS ?? 9e5),
metadata: { runtime_scope: "code_tool_task" }
});
}
return {
extensionOverrides: extensions,
enableContextMemory: Boolean(
capabilities.context_memory || capabilities.chat_recall || capabilities.static_publish
),
gooseMode: resolveAgentGooseMode(capabilities, policies)
};
}
function normalizeCapabilityPatch(patch) {
const normalized = {};
for (const [key, value] of Object.entries(patch ?? {})) {
if (!isValidCapabilityKey(key)) continue;
normalized[key] = Boolean(value);
}
return normalized;
}
// user-publish.mjs
import fs3 from "node:fs";
import path5 from "node:path";
import { fileURLToPath as fileURLToPath3 } from "node:url";
// mindspace-canonical-url.mjs
var MINDSPACE_PUBLIC_ROOT = "MindSpace";
var MINDSPACE_PUBLIC_ZONE = "public";
function urlError(message, code, details = {}) {
return Object.assign(new Error(message), { code, ...details });
}
function requiredSegment(value, field) {
const normalized = String(value ?? "").trim();
if (!normalized) {
throw urlError(`${field} is required`, "invalid_mindspace_url_segment", { field });
}
if (normalized.includes("/") || normalized.includes("\\") || normalized.includes("\0")) {
throw urlError(`${field} must be a single URL segment`, "invalid_mindspace_url_segment", {
field
});
}
return normalized;
}
function normalizeMindSpacePublicBaseUrl(publicBaseUrl) {
const normalized = String(publicBaseUrl ?? "").trim().replace(/\/+$/, "");
if (!normalized) {
throw urlError("publicBaseUrl is required", "invalid_mindspace_public_base_url");
}
return normalized;
}
function normalizeMindSpacePublicRelativePath(relativePath) {
const raw = String(relativePath ?? "").trim();
if (!raw) {
throw urlError("relativePath is required", "invalid_mindspace_public_relative_path");
}
if (raw.includes("\0") || raw.includes("\\")) {
throw urlError("relativePath must use URL-style forward slashes", "invalid_mindspace_public_relative_path");
}
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(raw) || raw.startsWith("//")) {
throw urlError("relativePath must not be an absolute URL", "invalid_mindspace_public_relative_path");
}
const clean = raw.replace(/^\/+/, "");
const parts = clean.split("/").filter(Boolean);
if (parts.length === 0 || parts.includes("..")) {
throw urlError("relativePath must not traverse parent directories", "invalid_mindspace_public_relative_path");
}
return parts.join("/");
}
function createMindSpacePublicUrl({ publicBaseUrl, ownerKey, relativePath }) {
const base = normalizeMindSpacePublicBaseUrl(publicBaseUrl);
const owner = requiredSegment(ownerKey, "ownerKey");
const relative = normalizeMindSpacePublicRelativePath(relativePath);
const encodedRelative = relative.split("/").map(encodeURIComponent).join("/");
return `${base}/${MINDSPACE_PUBLIC_ROOT}/${encodeURIComponent(owner)}/${encodedRelative}`;
}
function createMindSpaceUserRootUrl({ publicBaseUrl, ownerKey }) {
const base = normalizeMindSpacePublicBaseUrl(publicBaseUrl);
const owner = requiredSegment(ownerKey, "ownerKey");
return `${base}/${MINDSPACE_PUBLIC_ROOT}/${encodeURIComponent(owner)}/`;
}
function createMindSpacePublicPageUrl({ publicBaseUrl, ownerKey, filename }) {
const clean = normalizeMindSpacePublicRelativePath(filename);
const relative = clean.startsWith(`${MINDSPACE_PUBLIC_ZONE}/`) ? clean : `${MINDSPACE_PUBLIC_ZONE}/${clean}`;
return createMindSpacePublicUrl({ publicBaseUrl, ownerKey, relativePath: relative });
}
// user-publish.mjs
var __dirname2 = path5.dirname(fileURLToPath3(import.meta.url));
var PUBLISH_SKILL_NAME = "static-page-publish";
var DOCX_SKILL_NAME = "docx-generate";
var LONG_IMAGE_SKILL_NAME = "long-image-download";
var PUBLISH_ROOT_DIR = "MindSpace";
var PUBLIC_ZONE_DIR = "public";
var PUBLISH_SKILL_DIR = path5.join(__dirname2, "skills", PUBLISH_SKILL_NAME);
var DOCX_SKILL_DIR = path5.join(__dirname2, "skills", DOCX_SKILL_NAME);
var LONG_IMAGE_SKILL_DIR = path5.join(__dirname2, "skills", LONG_IMAGE_SKILL_NAME);
var WORKSPACE_HINTS_FILENAME = ".tkmindhints";
var LEGACY_WORKSPACE_HINTS_FILENAME = ".goosehints";
function renderBrandingBlock(userAddressName) {
const name = userAddressName || "\u7528\u6237";
return `## \u54C1\u724C\u4E0E\u79F0\u547C\uFF08\u786C\u6027\uFF09
- \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4ECB\u7ECD\u4EA7\u54C1\u65F6\u7528 TKMind\uFF0C\u4E0D\u8981\u79F0 goose\u3001Goose\u3001goosed
- \u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\uFF0C\u7528 **${name}** \u79F0\u547C\u7528\u6237\uFF08\u53EF\u8F85\u4EE5\u300C\u4F60/\u60A8\u300D\uFF09\uFF0C**\u7981\u6B62**\u628A\u7528\u6237\u53EB\u4F5C TKMind
- \u4EC5\u5728\u5F00\u573A\u6216\u7528\u6237\u6253\u62DB\u547C\u65F6\u4F7F\u7528\u65F6\u6BB5\u95EE\u5019\uFF0C\u4E14\u987B\u4E0E\u300CTKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6\u300D\u4E00\u81F4\uFF08\u5982\u300C${name}\uFF0C\u65E9\u4E0A\u597D\u300D\uFF09\uFF1B\u666E\u901A\u56DE\u590D\u76F4\u63A5\u4F5C\u7B54\uFF0C\u4E0D\u8981\u6BCF\u6761\u90FD\u52A0\u95EE\u5019\uFF1B\u7981\u6B62\u628A\u7528\u6237\u53EB\u4F5C TKMind
- \u4E0D\u8981\u63CF\u8FF0\u672C\u5DE5\u4F5C\u533A\u4E3A\u300CRust goose \u9879\u76EE\u300D\u6216\u300Cgoose AI \u6846\u67B6\u300D
- \u672C\u5DE5\u4F5C\u533A\u662F TKMind **MindSpace \u7528\u6237\u7A7A\u95F4**\uFF0C\u7528\u4E8E\u6587\u4EF6\u7BA1\u7406\u4E0E\u9759\u6001\u9875\u9762\u751F\u6210
`;
}
var INTERNAL_ID_PATTERN = /^wx_[a-z0-9_]{4,64}$|^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function resolveUserAddressName({ displayName, username, slug } = {}) {
const preferred = String(displayName ?? "").trim();
if (preferred && !INTERNAL_ID_PATTERN.test(preferred)) return preferred;
const uname = String(username ?? "").trim();
if (uname && !INTERNAL_ID_PATTERN.test(uname)) return uname;
const fallback = String(slug ?? "").trim();
if (fallback && !INTERNAL_ID_PATTERN.test(fallback)) return fallback;
return "\u7528\u6237";
}
function resolvePublicBaseUrl(env = process.env) {
return (env.H5_PUBLIC_BASE_URL ?? "https://mm.tkmind.cn").replace(/\/$/, "");
}
function resolvePublishKey(user) {
if (!user?.id) throw new Error("\u7F3A\u5C11\u7528\u6237 ID");
return String(user.id).trim().toLowerCase();
}
function resolveUsernameSlug(user) {
if (!user?.username) throw new Error("\u7F3A\u5C11\u7528\u6237\u540D");
return String(user.username).trim().toLowerCase();
}
function resolvePublishDir(h5Root, user) {
return path5.join(h5Root, PUBLISH_ROOT_DIR, resolvePublishKey(user));
}
function resolveGoosedSandboxRoot(h5Root, user, env = process.env) {
const canonicalRoot = String(
env.GOOSED_SANDBOX_PUBLISH_ROOT ?? env.MEMIND_SHARED_PUBLISH_ROOT ?? ""
).trim();
if (canonicalRoot) {
return path5.join(path5.resolve(canonicalRoot), resolvePublishKey(user));
}
return resolvePublishDir(h5Root, user);
}
function resolveLegacyPublishDir(h5Root, user) {
if (!user?.username) return null;
return path5.join(h5Root, PUBLISH_ROOT_DIR, resolveUsernameSlug(user));
}
function mergePublishTrees(sourceDir, targetDir) {
if (!fs3.existsSync(sourceDir)) return;
for (const entry of fs3.readdirSync(sourceDir, { withFileTypes: true })) {
const from = path5.join(sourceDir, entry.name);
const to = path5.join(targetDir, entry.name);
if (entry.isDirectory()) {
fs3.mkdirSync(to, { recursive: true });
mergePublishTrees(from, to);
} else if (!fs3.existsSync(to)) {
fs3.copyFileSync(from, to);
}
}
}
function migrateUserPublishDir(h5Root, user, legacyUsersRoot = null) {
const target = resolvePublishDir(h5Root, user);
fs3.mkdirSync(target, { recursive: true });
const legacyDirs = [
resolveLegacyPublishDir(h5Root, user),
legacyUsersRoot ? path5.join(legacyUsersRoot, resolveUsernameSlug(user)) : null
].filter(Boolean);
for (const legacyDir of legacyDirs) {
if (path5.resolve(legacyDir) === path5.resolve(target)) continue;
mergePublishTrees(legacyDir, target);
}
return target;
}
function buildPublicUrl(publicBaseUrl, publishKey, filename = "") {
if (!filename) {
return createMindSpaceUserRootUrl({ publicBaseUrl, ownerKey: publishKey });
}
return createMindSpacePublicUrl({ publicBaseUrl, ownerKey: publishKey, relativePath: filename });
}
function buildPublicZonePageUrl(publicBaseUrl, publishKey, filename) {
return createMindSpacePublicPageUrl({ publicBaseUrl, ownerKey: publishKey, filename });
}
function renderPublishSkill({ slug, username, publicBaseUrl, publishDir, displayName }) {
const addressName = resolveUserAddressName({ displayName, username, slug });
const exampleUrl = buildPublicZonePageUrl(publicBaseUrl, slug, "report.html");
return `---
name: ${PUBLISH_SKILL_NAME}
description: \u5728\u4E13\u5C5E MindSpace \u76EE\u5F55\u751F\u6210\u53EF\u516C\u5F00\u8BBF\u95EE\u7684\u9759\u6001 HTML \u62A5\u544A\u4E0E\u9875\u9762\uFF08TKMind H5\uFF09
---
# \u9759\u6001\u9875\u9762 / \u62A5\u544A\u53D1\u5E03\uFF08TKMind\uFF09
\u5F53\u7528\u6237\u9700\u8981**\u7F51\u9875\u3001HTML \u62A5\u544A\u3001\u53EF\u89C6\u5316\u9875\u9762\u3001\u53EF\u5206\u4EAB\u94FE\u63A5**\u65F6\uFF0C**\u5FC5\u987B\u5148\u52A0\u8F7D\u672C\u6280\u80FD**\u5E76\u6309\u4EE5\u4E0B\u89C4\u5219\u6267\u884C\u3002
## \u786C\u6027\u7EA6\u675F\uFF08\u4E0D\u53EF\u8FDD\u53CD\uFF09
1. **\u552F\u4E00\u53EF\u5199\u76EE\u5F55**\uFF1A\`${publishDir}\`
2. **\u7981\u6B62**\u4F7F\u7528\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u5982 \`/Users/...\`\u3001\`../\` \u8DF3\u51FA\u76EE\u5F55\uFF09
3. **\u5141\u8BB8**\u5728\u672C\u76EE\u5F55\u5185\u4F7F\u7528 \`write_file\`\u3001\`edit_file\`\u3001\`read_file\`\u3001\`list_dir\`\u3001\`generate_docx\`\u3001\`generate_long_image\`\uFF1B**\u7981\u6B62**\u8BBF\u95EE\u6B64\u76EE\u5F55\u5916\u7684\u8DEF\u5F84\uFF08\u542B \`../\`\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\u9879\u76EE\u6839\u76EE\u5F55\uFF1B\u7CFB\u7EDF\u4F1A\u5728 OS \u5C42\u62E6\u622A\u8D8A\u754C\u8BBF\u95EE\uFF09
4. **\u7981\u6B62**\u5B50 Agent\u3001\u6269\u5C55\u7BA1\u7406\u3001\u4FEE\u6539\u5DE5\u4F5C\u533A\u5916\u6587\u4EF6
5. \u9875\u9762\u9ED8\u8BA4\u5199\u5165 \`public/\` \u5206\u533A\uFF0C\u4F7F\u7528\u76F8\u5BF9\u8DEF\u5F84\u5982 \`public/report.html\`\u3001\`public/assets/chart.png\`
6. \u751F\u6210 HTML \u540E\uFF0C\u5411\u7528\u6237\u63D0\u4F9B\u53EF\u8BBF\u95EE\u94FE\u63A5\uFF0C\u683C\u5F0F\uFF1A
\`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/<\u6587\u4EF6\u540D>\`
\uFF08\u5199\u5165 \`public/\u9875\u9762.html\` \u65F6 URL **\u5FC5\u987B**\u542B \`public/\`\uFF1B\u4EC5\u5F53 HTML \u76F4\u63A5\u5199\u5728 workspace \u6839\u76EE\u5F55\u65F6\u624D\u7701\u7565\uFF09
## \u63A8\u8350\u5DE5\u4F5C\u6D41
1. \u786E\u8BA4\u9700\u6C42\uFF08\u6807\u9898\u3001\u7AE0\u8282\u3001\u662F\u5426\u8981\u56FE\u8868/\u6837\u5F0F\uFF09
2. \u4F7F\u7528 \`write_file\` \u521B\u5EFA \`public/\u9875\u9762.html\`\uFF08\u53EF\u542B\u5185\u8054 CSS\uFF1B\u9700\u8981\u65F6\u5728 \`public/assets/\` \u6216\u5DE5\u4F5C\u533A \`assets/\` \u653E\u8D44\u6E90\uFF09
3. \u5728 \`<head>\` \u5199\u5165 **mindspace-cover** \u5143\u6570\u636E\uFF08\u89C1\u4E0B\u6587\uFF0C\u5FC5\u987B\u4E0E\u9875\u9762\u4E3B\u9898\u4E00\u81F4\uFF09
4. \u9875\u9762\u5185\u8D44\u6E90\u4F7F\u7528**\u76F8\u5BF9\u8DEF\u5F84**\uFF08\`assets/foo.png\`\uFF09\uFF0C\u4E0D\u8981\u7528\u78C1\u76D8\u7EDD\u5BF9\u8DEF\u5F84
5. \u4FDD\u5B58 HTML \u540E\uFF0C\u670D\u52A1\u7AEF\u4F1A**\u7ACB\u5373**\u751F\u6210\u540C\u540D\u9884\u89C8\u56FE \`<\u6587\u4EF6\u540D>.thumbnail.svg\`\uFF08Agent \u4EA4\u4E92\u9636\u6BB5\u5373\u751F\u6548\uFF0C\u65E0\u9700\u7B49\u7528\u6237\u4FDD\u5B58\u5230\u300C\u6211\u7684\u7A7A\u95F4\u300D\uFF09
6. \u9ED8\u8BA4\u53EA\u751F\u6210 HTML\uFF1B\u4E0D\u8981\u5728\u6CA1\u6709\u660E\u786E\u9700\u6C42\u65F6\u5F3A\u5236\u751F\u6210 Word\u3001PDF\u3001\u957F\u56FE\u7B49\u4F34\u751F\u6587\u4EF6
7. \u82E5\u7528\u6237\u660E\u786E\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF0C\u4E0B\u8F7D\u6309\u94AE\u7684\u76F8\u5BF9\u94FE\u63A5\u76EE\u6807\uFF08\u5982 \`public/report.docx\`\uFF09\u5FC5\u987B\u901A\u8FC7 \`generate_docx\` \u843D\u76D8\uFF0C\u4E14 basename \u4E0E HTML \u4E00\u81F4
8. \u82E5\u7528\u6237\u660E\u786E\u8981\u6C42\u957F\u56FE\u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`long-image-download\`\uFF0C\u8C03\u7528 \`generate_long_image\` \u751F\u6210\u5E76\u786E\u8BA4 \`public/<\u9875\u9762\u540D>.long.png\` \u5DF2\u5B58\u5728
9. \u5B8C\u6210\u540E\u6309\u300C\u56DE\u590D\u683C\u5F0F\u300D\u8FD4\u56DE\u53EF\u70B9\u51FB\u94FE\u63A5
## \u6309\u9700\u4F34\u751F\u4E0B\u8F7D\u6587\u4EF6
- \u9ED8\u8BA4\u4E0D\u751F\u6210\u4F34\u751F\u6587\u4EF6\uFF1B\u53EA\u6709\u7528\u6237\u660E\u786E\u8981\u6C42\u4E0B\u8F7D\u9644\u4EF6\u65F6\u624D\u751F\u6210
- \u76F8\u5BF9\u8DEF\u5F84\u4E0B\u8F7D\u94FE\u63A5\uFF08\`.docx\` / \`.pdf\` \u7B49\uFF09\u5FC5\u987B\u5728 HTML \u540C\u76EE\u5F55\u6216\u5B50\u76EE\u5F55\u771F\u5B9E\u5B58\u5728
- Word \u6587\u6863\u5FC5\u987B\u7528 sandbox-fs \u7684 \`generate_docx\` \u751F\u6210\uFF1B\u7981\u6B62\u7528 \`computercontroller\` / shell \u751F\u6210\u751F\u4EA7\u4E0B\u8F7D\u6587\u4EF6\u4F5C\u4E3A\u4EA4\u4ED8\u4F9D\u636E
- \u6539 HTML \u6587\u4EF6\u540D\u65F6\u540C\u6B65 rename/copy \u4F34\u751F\u6587\u4EF6\uFF1B\u4ECE \`oa/\` \u590D\u5236\u5230 \`public/\` \u518D\u94FE\u63A5
- \u53EF\u8DD1 \`node scripts/check-mindspace-public-links.mjs\` \u81EA\u68C0
## Word \u4E0B\u8F7D\u9875\uFF08\u6309\u9700\uFF09
- \u5982\u679C\u7528\u6237\u660E\u786E\u8981\u6C42\u9875\u9762\u91CC**\u53EF\u4E0B\u8F7D Word / docx**\uFF0C\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`docx-generate\`
- \u7528 \`.agents/skills/docx-generate/generate_docx.py\` \u751F\u6210 \`public/\u6587\u4EF6\u540D.docx\`
- \u751F\u6210\u540E\u5FC5\u987B\u7528 \`list_dir public\`\uFF08\u6216\u7B49\u4EF7\u65B9\u5F0F\uFF09\u786E\u8BA4\u76EE\u6807 \`.docx\` \u5DF2\u5B58\u5728\uFF0C\u518D\u5199 \`public/\u9875\u9762.html\`
- HTML \u4E2D\u53EA\u80FD\u4F7F\u7528\u540C\u76EE\u5F55\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u6587\u6863\uFF0C\u4F8B\u5982 \`<a href="report.docx" download>\u4E0B\u8F7D Word \u6587\u6863</a>\`
- **\u7981\u6B62**\u53EA\u5199\u4E0B\u8F7D\u6309\u94AE\u6216 \`.docx\` \u94FE\u63A5\uFF0C\u5374\u6CA1\u6709\u5148\u628A\u76EE\u6807\u6587\u6863\u843D\u76D8
## \u56DE\u590D\u683C\u5F0F\uFF08\u5FC5\u987B\uFF09
\u5411\u7528\u6237\u4EA4\u4ED8\u9875\u9762\u65F6\uFF0C**\u5FC5\u987B\u4F7F\u7528 Markdown \u53EF\u70B9\u51FB\u94FE\u63A5**\uFF0C\u8BA9\u7528\u6237\u5728\u804A\u5929\u91CC\u76F4\u63A5\u70B9\u5F00\u9884\u89C8\uFF1A
\`\`\`markdown
[\u666E\u62C9\u63D0 618 \u6D3B\u52A8\u9875](${exampleUrl})
\`\`\`
\u8981\u6C42\uFF1A
- **\u5FC5\u987B**\u4F7F\u7528 \`[\u6807\u9898](URL)\` \u683C\u5F0F\uFF0C\u4E0D\u8981\u53EA\u7ED9\u88F8 URL
- \u6807\u9898\u7528\u9875\u9762\u771F\u5B9E\u4E3B\u9898\uFF08\u4E0D\u8981\u7528\u300C\u70B9\u51FB\u8FD9\u91CC\u300D\u300C\u94FE\u63A5\u300D\uFF09
- \u53EF\u540C\u65F6\u7ED9\u51FA\u672C\u5730\u76F8\u5BF9\u8DEF\u5F84\uFF08\u5982 \`public/pilates-618.html\`\uFF09\u4E0E\u516C\u7F51\u94FE\u63A5
- \u8BF4\u660E\uFF1A\u9759\u6001\u6587\u4EF6\u4FDD\u5B58\u5373\u751F\u6548\uFF0C**\u65E0\u9700\u91CD\u542F**
- \u82E5\u751F\u6210\u4E86\u957F\u56FE\uFF0C\u540C\u65F6\u7ED9 \`[\u957F\u56FE\u9884\u89C8](.../public/pilates-618.long.png)\` \u548C \`[\u4E0B\u8F7D\u957F\u56FE](.../public/pilates-618.html?download=long-image)\`
## \u4FE1\u606F\u6D41\u9884\u89C8\u56FE\uFF08\u5FC5\u987B\uFF09
\u6BCF\u4E2A HTML \u9875\u9762\u90FD\u5FC5\u987B\u5728 \`<head>\` \u5305\u542B\u4E0E**\u9875\u9762\u4E3B\u9898\u4E00\u81F4**\u7684\u9884\u89C8\u5143\u6570\u636E\u3002\u7CFB\u7EDF\u636E\u6B64\u751F\u6210 **\u7CBE\u7F8E\u7684 3:4 \u4FE1\u606F\u6D41\u5C01\u9762**\uFF08\u300C\u6211\u7684\u7A7A\u95F4\u300D\u5361\u7247 + \u5DE5\u4F5C\u533A \`.thumbnail.svg\` + \u4FDD\u5B58\u5F39\u7A97\u9884\u89C8\uFF09\uFF1A
\`\`\`html
<meta name="description" content="\u4E00\u53E5\u8BDD\u6458\u8981\uFF0C\u663E\u793A\u5728\u9884\u89C8\u56FE\u526F\u6807\u9898">
<meta name="mindspace-cover" content='{"tag":"\u8FD0\u52A8","emoji":"\u{1F9D8}","accent":"#eeb04e","accent2":"#1a0a2e","subtitle":"618 \u9650\u65F6 5 \u8282\u8BFE","cover":"assets/hero.jpg"}'>
\`\`\`
| \u5B57\u6BB5 | \u8981\u6C42 |
|------|------|
| \`tag\` | \u4E0E\u9875\u9762\u4E3B\u9898\u4E00\u81F4\uFF1A\u65C5\u884C / \u7F8E\u98DF / \u62A5\u544A / **\u8FD0\u52A8** / **\u6D3B\u52A8** \u7B49\uFF1B\u51B3\u5B9A\u9ED8\u8BA4\u914D\u8272 |
| \`accent\` / \`accent2\` | \u4ECE\u9875\u9762\u4E3B\u8272\u63D0\u53D6\uFF0C\u4E0E hero/\u80CC\u666F\u4E00\u81F4 |
| \`subtitle\` | \u4E00\u53E5\u8BDD\u5356\u70B9\uFF1B\u672A\u5199\u65F6\u7528 description |
| \`cover\` / \`image\` | **\u5FC5\u987B**\u6307\u5411\u9AD8\u8D28\u91CF\u4E3B\u56FE\uFF08\u76F8\u5BF9 HTML \u6216 https\uFF09\uFF1B\u89C1\u4E0B\u6587 |
| \`emoji\` | \u53EF\u9009\uFF1B\u4E5F\u53EF\u5199\u5728 title \u4E2D |
### \u7CBE\u7F8E\u9884\u89C8\u56FE\uFF08\u5FC5\u987B\u8FBE\u6807\uFF09
\u4FDD\u5B58 HTML \u540E\uFF0C\u670D\u52A1\u7AEF\u4F1A**\u7ACB\u5373**\u751F\u6210\u540C\u540D\u9884\u89C8\u56FE \`<\u6587\u4EF6\u540D>.thumbnail.svg\`\uFF08Agent \u4EA4\u4E92\u9636\u6BB5\u5373\u751F\u6548\uFF0C\u65E0\u9700\u7B49\u7528\u6237\u4FDD\u5B58\u5230\u300C\u6211\u7684\u7A7A\u95F4\u300D\uFF09\u3002\u8981\u4EA7\u51FA**\u53EF\u5728\u4FE1\u606F\u6D41\u4E2D\u76F4\u63A5\u5C55\u793A\u7684\u7CBE\u7F8E\u5C01\u9762**\uFF0C\u5FC5\u987B\uFF1A
1. **\u89C6\u89C9\u7C7B\u9875\u9762**\uFF08\u65C5\u884C\u3001\u7F8E\u98DF\u3001\u6D3B\u52A8\u3001\u8FD0\u52A8\u3001\u54C1\u724C\u3001\u4EA7\u54C1\u3001\u4FC3\u9500\u7B49\uFF09**\u5FC5\u987B**\u5728 \`assets/\` \u653E\u7F6E\u9AD8\u8D28\u91CF hero \u4E3B\u56FE\uFF08\u5EFA\u8BAE\u5BBD\u5EA6 \u22651200px\uFF09\uFF0C\u5E76\u5728 \`cover\` \u5B57\u6BB5\u5F15\u7528
2. **\u7EAF\u6587\u5B57\u62A5\u544A**\u53EF\u4EC5\u7528\u914D\u8272 + tag\uFF0C\u4F46\u4ECD\u987B\u4FDD\u8BC1 \`accent\` / \`subtitle\` \u4E0E\u9875\u9762\u98CE\u683C\u4E00\u81F4
3. \`tag\`\u3001\`accent\`\u3001\`accent2\`\u3001\`subtitle\` \u5FC5\u987B\u4E0E\u9875\u9762\u5B9E\u9645\u89C6\u89C9\u4E00\u81F4\uFF1B**\u7981\u6B62**\u7701\u7565 mindspace-cover \u6216\u586B\u65E0\u5173\u9ED8\u8BA4\u503C
4. \u82E5\u7F3A\u5C11 hero \u4E3B\u56FE\uFF0C\u5C01\u9762\u4F1A\u9000\u5316\u4E3A\u7B80\u964B\u9ED8\u8BA4\u56FE\uFF0C**\u89C6\u4E3A\u672A\u8FBE\u6807**
**\u7981\u6B62**\u4F7F\u7528\u4E0E\u9875\u9762\u65E0\u5173\u7684\u901A\u7528\u98CE\u666F\u56FE\u903B\u8F91\uFF1B\u4FC3\u9500/\u8FD0\u52A8/\u54C1\u724C\u9875\u5FC5\u987B\u5199\u660E \`tag\`\u3001\`accent\` \u548C \`cover\`\u3002
## HTML \u6A21\u677F\u5EFA\u8BAE
- \u5B8C\u6574 \`<!DOCTYPE html>\`\uFF0C\`lang="zh-CN"\`
- \u79FB\u52A8\u7AEF\u53CB\u597D\uFF1A\`<meta name="viewport" content="width=device-width, initial-scale=1">\`
- \u6DF1\u8272/\u6D45\u8272\u4E0E\u5185\u5BB9\u4E00\u81F4\uFF1B\u4E3B\u8272\u5728 CSS \u4E0E mindspace-cover \u4E2D\u4FDD\u6301\u4E00\u81F4
- \u9875\u811A\u5E73\u53F0\u54C1\u724C\u884C**\u5FC5\u987B**\u4F7F\u7528 \`data-mindspace-page-tag="platform-brand"\` \u6807\u8BB0\uFF0C\u663E\u793A\u4E3A **TKMind \xB7 \u667A\u8DA3**\uFF0C**\u7981\u6B62**\u4F7F\u7528\u90AE\u7BB1\u6216 \`tkmind.ai\`
\`\`\`html
<p data-mindspace-page-tag="platform-brand">TKMind \xB7 \u667A\u8DA3</p>
\`\`\`
- \u5E26 \`data-mindspace-page-tag\` \u7684\u533A\u57DF\u4E3A\u5E73\u53F0\u56FA\u5B9A\u4FE1\u606F\uFF1A\u7528\u6237\u5728\u7F16\u8F91\u6A21\u5F0F\u4E2D\u4E0D\u53EF\u89C1\u3001\u4E0D\u53EF\u6539\uFF1B\u9884\u89C8\u4E0E\u53D1\u5E03\u540E\u6B63\u5E38\u663E\u793A
## \u67E5\u627E\u6587\u4EF6\uFF08CSV\u3001\u6587\u6863\u7B49\uFF09
- \u53EA\u5728**\u5F53\u524D\u5DE5\u4F5C\u533A**\u5185\u4ECE \`.\` \u641C\u7D22\uFF08\u5982 \`list_dir\`\u3001\`list_dir oa\`\uFF0C\u6216 shell \u53EF\u7528\u65F6 \`find . -name '*.csv'\`\uFF09
- \u7528\u6237\u8BF4\u7684\u300COA \u533A\u300D\u7B49\u89C6\u4E3A\u5DE5\u4F5C\u533A**\u5B50\u76EE\u5F55**\uFF08\u5982 \`oa/\`\uFF09\uFF0C\u7528\u76F8\u5BF9\u8DEF\u5F84\u67E5\u627E
- **\u7981\u6B62**\u641C\u7D22\u4E0A\u7EA7\u76EE\u5F55\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\`${PUBLISH_ROOT_DIR}\` \u6839\u76EE\u5F55\u3001\`/Users\`\u3001\u9879\u76EE\u6839\u76EE\u5F55
- \u627E\u4E0D\u5230\u65F6\u544A\u77E5\u7528\u6237\u4E0A\u4F20\u6216\u63D0\u4F9B\u76F8\u5BF9\u8DEF\u5F84\uFF0C**\u4E0D\u8981**\u6269\u5927\u5230\u5DE5\u4F5C\u533A\u5916
## \u7981\u6B62\u4E8B\u9879
- \u4E0D\u8981\u5199\u5165\u5F53\u524D\u5DE5\u4F5C\u533A\u4EE5\u5916\u7684\u4EFB\u4F55\u76EE\u5F55
- shell \u4EC5\u7528\u4E8E\u672C\u76EE\u5F55\u5185\u6574\u7406\u6587\u4EF6/\u7B80\u5355\u811A\u672C\uFF1B\u4E0D\u8981 \`rm -rf\` \u8D8A\u754C\u8DEF\u5F84\u3001\u4E0D\u8981\u5B89\u88C5\u7CFB\u7EDF\u7EA7\u4F9D\u8D56
- **\u7981\u6B62**\u5728 HTML \u4E2D\u7528 \`data:...;base64,...\` \u5185\u5D4C Word/PDF\uFF1B\u4E8C\u8FDB\u5236\u6587\u4EF6\u5355\u72EC\u843D\u76D8\u540E\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\uFF08\u89C1 \`docx-generate\` \u6280\u80FD\uFF09
- \u7528\u6237\u8981\u6C42 Word / docx \u4E0B\u8F7D\u65F6\uFF0C\u7981\u6B62\u8DF3\u8FC7 \`docx-generate\` \u76F4\u63A5\u5728 HTML \u4E2D\u4F2A\u9020\u4E0B\u8F7D\u94FE\u63A5
- \u7528\u6237\u8981\u6C42\u957F\u56FE\u4E0B\u8F7D\u65F6\uFF0C\u7981\u6B62\u8DF3\u8FC7 \`long-image-download\` \u6216\u628A \`.thumbnail.svg\` \u5F53\u6210\u957F\u56FE
## \u793A\u4F8B
\u7528\u6237\uFF1A\u300C\u5199\u4E00\u4EFD\u8D8A\u5357\u65C5\u6E38\u6307\u5357\u7F51\u9875\u300D
\u6B63\u786E\u505A\u6CD5\uFF1A
- \`write_file\` \u2192 \`public/vietnam-guide.html\`\uFF08\u5185\u5BB9\u5B8C\u6574\uFF09
- \u56DE\u590D\u94FE\u63A5\uFF1A\`${buildPublicZonePageUrl(publicBaseUrl, slug, "vietnam-guide.html")}\`
`;
}
function renderWorkspaceHints({ slug, username, publishDir, displayName }) {
const addressName = resolveUserAddressName({ displayName, username, slug });
return `# TKMind \u7528\u6237\u5DE5\u4F5C\u533A\u8FB9\u754C
\u4F60\u662F\u7528\u6237 **${addressName}** \u7684\u4E13\u5C5E TKMind \u52A9\u624B\u3002\u5F53\u524D\u4F1A\u8BDD**\u552F\u4E00**\u6587\u4EF6\u6839\u76EE\u5F55\uFF1A
\`${publishDir}\`
${renderBrandingBlock(addressName)}
## \u67E5\u627E / \u8BFB\u53D6\u6587\u4EF6\uFF08CSV\u3001\u6587\u6863\u3001OA \u533A\u7B49\uFF09
1. **\u53EA\u80FD**\u5728\u672C\u5DE5\u4F5C\u533A\u5185\u641C\u7D22\uFF0C\u4F8B\u5982 \`list_dir\`\u3001\`list_dir oa\`\uFF0C\u6216 shell \u53EF\u7528\u65F6 \`find . -name '*.csv'\`\u3001\`rg keyword .\`
2. \u7528\u6237\u8BF4\u7684\u300COA \u533A\u300D\u300C\u6587\u6863\u533A\u300D\u7B49\uFF0C**\u5148\u5F53\u4F5C\u5DE5\u4F5C\u533A\u5185\u7684\u5B50\u76EE\u5F55**\uFF08\u5982 \`oa/\`\u3001\`docs/\`\uFF09\uFF0C\u53EA\u7528\u76F8\u5BF9\u8DEF\u5F84\u67E5\u627E
3. **\u7EDD\u5BF9\u7981\u6B62**\uFF1A
- \u53BB\u4E0A\u7EA7\u76EE\u5F55\u3001\`${PUBLISH_ROOT_DIR}\` \u6839\u76EE\u5F55\u3001\u5176\u5B83\u7528\u6237\u540D\u76EE\u5F55\u3001\u9879\u76EE\u6839\u76EE\u5F55\u641C\u7D22
- \u4F7F\u7528 \`/Users\`\u3001\`/home\`\u3001Desktop\u3001Documents\u3001Downloads \u7B49\u4E3B\u673A\u8DEF\u5F84
- \u5728\u56DE\u590D\u4E2D\u5EFA\u8BAE\u300C\u5728 MindSpace \u76EE\u5F55\u6216\u9644\u8FD1\u76EE\u5F55\u641C\u7D22\u300D\u2014\u2014\u4F60\u6CA1\u6709\u8FD9\u4E2A\u6743\u9650
4. \u627E\u4E0D\u5230\u6587\u4EF6\u65F6\uFF1A\u8BF4\u660E\u5728\u5176\u5DE5\u4F5C\u533A\u5185\u672A\u627E\u5230\uFF0C\u8BF7\u7528\u6237\u4E0A\u4F20\u6216\u63D0\u4F9B\u76F8\u5BF9\u8DEF\u5F84\uFF1B**\u4E0D\u5F97**\u6269\u5927\u5230\u5DE5\u4F5C\u533A\u5916
## \u516C\u7F51\u94FE\u63A5 vs \u672C\u5730\u6587\u4EF6
- \u516C\u7F51 URL\uFF08\u5982 \`https://\u2026/MindSpace/<\u7528\u6237>/\`\uFF09**\u4EC5\u4F9B\u7528\u6237\u6D4F\u89C8\u5668\u6253\u5F00\u5DF2\u53D1\u5E03\u7684 HTML \u9875\u9762**
- **\u7981\u6B62**\u7528\u516C\u7F51 URL\u3001curl\u3001wget \u6216 read_image \u53BB\u300C\u5217\u76EE\u5F55\u300D\u300C\u8BFB CSV\u300D\u2014\u2014\u9759\u6001\u7AD9\u70B9\u4E0D\u63D0\u4F9B\u76EE\u5F55\u7D22\u5F15
- \u6709 \`shell\` \u65F6\u7528 \`ls oa/\`\u3001\`find .\`\u3001\`cat oa/file.csv\`\uFF1B\u6709 \`tree\` \u65F6\u7528 \`tree oa/\`
- \u4E0D\u8981\u8BF4\u300C\u6211\u6CA1\u6709 shell\u300D\u2014\u2014\u82E5\u7CFB\u7EDF\u63D0\u793A\u5DF2\u5217\u51FA shell\uFF0C\u5C31\u5FC5\u987B\u7528 shell \u5728\u5DE5\u4F5C\u533A\u5185\u64CD\u4F5C
## Word \u4E0B\u8F7D\u9875
- \u82E5\u7528\u6237\u8981\u6C42\u9875\u9762\u91CC\u53EF\u4E0B\u8F7D Word / docx\uFF1A\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`docx-generate\`
- \u5148\u751F\u6210\u5E76\u786E\u8BA4 \`public/*.docx\` \u5DF2\u5B58\u5728\uFF0C\u518D\u5199 \`public/*.html\`
- HTML \u4E2D\u53EA\u80FD\u7528\u540C\u76EE\u5F55\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863\uFF0C\u7981\u6B62\u53EA\u5199\u4E0B\u8F7D\u6309\u94AE\u5374\u6CA1\u6709\u5148\u628A \`.docx\` \u843D\u76D8
## \u957F\u56FE\u4E0B\u8F7D
- \u82E5\u7528\u6237\u660E\u786E\u8981\u6C42\u957F\u56FE\u4E0B\u8F7D\uFF1A\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`long-image-download\`
- \u8C03\u7528 \`generate_long_image\` \u751F\u6210 \`public/<\u9875\u9762\u540D>.long.png\`
- \u5148\u786E\u8BA4 \`public/*.long.png\` \u5DF2\u5B58\u5728\uFF0C\u518D\u56DE\u590D \`[\u957F\u56FE\u9884\u89C8](...long.png)\` \u548C \`[\u4E0B\u8F7D\u957F\u56FE](...html?download=long-image)\`
- \`.thumbnail.svg\` \u53EA\u662F\u4FE1\u606F\u6D41\u5C01\u9762\uFF0C\u4E0D\u80FD\u5F53\u6210\u957F\u56FE
`;
}
function buildSandboxSessionConstraints({ baseConstraints, developerTools = [] }) {
const tools = developerTools.length > 0 ? developerTools : ["write_file", "edit_file"];
const hasSandboxMcp = tools.includes("write_file") || tools.includes("read_file");
const hasShell = tools.includes("shell");
const hasListDir = tools.includes("list_dir") || tools.includes("tree");
const lines = [
baseConstraints,
"",
"## \u5F53\u524D\u4F1A\u8BDD\u53EF\u7528\u6587\u4EF6\u5DE5\u5177",
`- ${tools.join(", ")}`,
hasSandboxMcp ? "- \u6587\u4EF6\u5DE5\u5177\u7531**\u6C99\u7BB1 MCP** \u63D0\u4F9B\uFF1A\u6240\u6709\u8DEF\u5F84\u5728 OS \u5C42\u5F3A\u5236\u9650\u5236\u5728\u5DE5\u4F5C\u533A\u5185\uFF0C\u8D8A\u754C\u8BBF\u95EE\u4F1A\u7ACB\u5373\u62A5\u9519" : ""
].filter((l) => l !== "");
if (hasShell) {
lines.push(
'- **shell \u5DF2\u5F00\u542F**\uFF1A\u53EA\u80FD\u5728\u672C\u5DE5\u4F5C\u533A\u5185\u6267\u884C\uFF08\u5982 `ls oa/`\u3001`find . -name "*.csv"`\u3001`cat oa/\u6587\u4EF6.csv`\uFF09',
"- **\u7981\u6B62** curl/wget \u6216\u8BBF\u95EE\u516C\u7F51 URL \u6765\u8BFB\u53D6\u5DE5\u4F5C\u533A\u6587\u4EF6"
);
} else {
lines.push(
"- **shell \u672A\u5F00\u542F**\uFF1A\u4E0D\u80FD\u6267\u884C shell\uFF1B\u4E5F\u4E0D\u8981\u7528\u516C\u7F51 URL \u4EE3\u66FF\u672C\u5730\u8BFB\u6587\u4EF6"
);
}
if (hasListDir) {
lines.push("- **list_dir \u53EF\u7528**\uFF1A`list_dir` \u6216 `list_dir oa` \u67E5\u770B\u76EE\u5F55\u7ED3\u6784");
} else {
lines.push("- \u9700\u8981\u5217\u76EE\u5F55\u6216\u8BFB CSV \u65F6\uFF0C\u8BF7\u7528\u6237\u4E0A\u4F20\u6587\u4EF6\u5185\u5BB9\u6216\u5F00\u542F list_dir \u80FD\u529B");
}
lines.push(
"",
"## \u751F\u6210 / \u53D1\u5E03 HTML \u9875\u9762",
"- \u4F60\u6709 write_file/edit_file \u5DE5\u5177\uFF1A**\u5FC5\u987B\u7531\u4F60**\u5199\u5165 `public/xxx.html`\uFF08\u6216\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 `.html`\uFF09",
"- \u5F00\u59CB\u524D\u6267\u884C load_skill \u2192 `static-page-publish`\uFF0C\u6309\u6280\u80FD\u8BF4\u660E\u5199\u5165 mindspace-cover \u5143\u6570\u636E",
"- \u9ED8\u8BA4\u53EA\u751F\u6210 HTML\uFF1B\u4E0D\u8981\u5728\u6CA1\u6709\u660E\u786E\u9700\u6C42\u65F6\u5F3A\u5236\u751F\u6210 Word\u3001PDF\u3001\u957F\u56FE\u7B49\u4F34\u751F\u6587\u4EF6",
"- \u53EA\u6709\u7528\u6237\u660E\u786E\u8981\u6C42 Word / docx \u4E0B\u8F7D\u65F6\uFF1A\u5148 `load_skill` \u2192 `docx-generate`\uFF0C\u751F\u6210\u5E76\u786E\u8BA4 `public/*.docx` \u5DF2\u5B58\u5728\uFF0C\u518D\u5199 HTML \u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5",
"- \u53EA\u6709\u7528\u6237\u660E\u786E\u8981\u6C42\u957F\u56FE\u4E0B\u8F7D\u65F6\uFF1A\u5148 `load_skill` \u2192 `long-image-download`\uFF0C\u8C03\u7528 `generate_long_image` \u751F\u6210\u5E76\u786E\u8BA4 `public/*.long.png` \u5DF2\u5B58\u5728\uFF0C\u518D\u56DE\u590D\u957F\u56FE\u9884\u89C8\u4E0E\u4E0B\u8F7D\u94FE\u63A5",
"- **\u7981\u6B62**\u7528 shell / cat / heredoc / echo / cp \u5199\u5165 HTML\uFF1Bshell \u5728\u5BB9\u5668\u5185\u6267\u884C\uFF0C\u6587\u4EF6\u4E0D\u4F1A\u51FA\u73B0\u5728\u516C\u7F51 MindSpace \u8DEF\u5F84",
"- \u7528 `apps__create_app` \u8BBE\u8BA1\u9875\u9762\u65F6\uFF0C\u6700\u540E\u4ECD\u8981\u6309 `static-page-publish` skill \u628A\u5185\u5BB9 write_file \u843D\u5230 `public/`\uFF0C\u624D\u6709\u516C\u7F51\u94FE\u63A5",
"- **\u7981\u6B62**\u8BA9\u7528\u6237\u624B\u52A8\u4FDD\u5B58\u5230 public \u6216\u8BF4\u65E0\u6CD5\u751F\u6210\u9875\u9762\uFF08\u9664\u975E write_file \u8C03\u7528\u5931\u8D25\uFF09",
"- \u5B8C\u6210\u540E\u56DE\u590D `[\u9875\u9762\u6807\u9898](\u516C\u7F51URL)` \u53EF\u70B9\u51FB\u94FE\u63A5\uFF1B\u5199\u5165 `public/` \u65F6 URL \u5FC5\u987B\u542B `/public/` \u8DEF\u5F84\u6BB5\uFF1B\u6309\u672C\u4F1A\u8BDD\u7ED9\u51FA\u7684\u516C\u7F51\u524D\u7F00\u6A21\u677F\u62FC\u63A5\u771F\u5B9E\u5730\u5740",
"- \u6309\u9700\u4E0B\u8F7D\u9644\u4EF6\uFF1AWord \u7528 `generate_docx` \u5199\u5165 `public/*.docx`\uFF0C\u76F8\u5BF9\u94FE\u63A5\u6587\u4EF6\u5FC5\u987B\u5DF2\u5728 `public/` \u843D\u76D8\uFF0Cbasename \u4E0E HTML \u4E00\u81F4",
"- \u6309\u9700\u957F\u56FE\u9644\u4EF6\uFF1A\u7528 `generate_long_image` \u5199\u5165 `public/*.long.png`\uFF0C\u4E0D\u8981\u628A `.thumbnail.svg` \u5F53\u6210\u957F\u56FE"
);
return lines.join("\n");
}
function buildPublishConstraints({ slug, username, publicBaseUrl, publishDir, displayName }) {
const addressName = resolveUserAddressName({ displayName, username, slug });
return [
"## TKMind \u7528\u6237\u7A7A\u95F4\u6C99\u7BB1\uFF08\u786C\u6027\u7EA6\u675F\uFF09",
"",
"- \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\u7528 **" + addressName + "** \u79F0\u547C\u7528\u6237\uFF0C\u7981\u6B62\u628A\u7528\u6237\u53EB\u4F5C TKMind",
"- \u7981\u6B62\u79F0 goose / Goose / goosed \u6216\u300CRust goose \u9879\u76EE\u300D",
`- \u5F53\u524D\u7528\u6237 **${addressName}** \u7684 Agent \u5DE5\u4F5C\u533A\uFF08\u552F\u4E00\u6587\u4EF6\u6839\u76EE\u5F55\uFF09\uFF1A\`${publishDir}\``,
"- \u7528\u6237\u4E0A\u4F20\u843D\u5728\u5206\u533A\u5B50\u76EE\u5F55\uFF1A`oa/`\u3001`private/`\u3001`public/`\uFF08\u5747\u5728\u4E0A\u8FF0\u5DE5\u4F5C\u533A\u5185\uFF09",
`- \u516C\u7F51 HTML \u524D\u7F00\uFF08\u516C\u5F00\u533A\u9875\u9762\uFF09\uFF1A\`${buildPublicUrl(publicBaseUrl, slug, `${PUBLIC_ZONE_DIR}/`)}\``,
"- **\u67E5\u627E\u6587\u4EF6**\uFF1A\u5728\u5DE5\u4F5C\u533A\u5185\u5BF9\u5E94\u5206\u533A\u641C\u7D22\uFF08\u5982 `oa/2025-12-06T13-34_export.csv`\uFF09\uFF0C\u4E0D\u8981\u641C\u5176\u5B83\u7528\u6237\u76EE\u5F55\u6216\u516C\u7F51 URL",
"- **\u7981\u6B62**\uFF1A\u8BBF\u95EE `assets/` \u5185\u90E8\u8DEF\u5F84\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\u4E3B\u673A\u7EDD\u5BF9\u8DEF\u5F84\uFF1B\u7981\u6B62\u7528\u516C\u7F51 URL \u5217\u76EE\u5F55\u6216\u8BFB CSV",
"- **\u8DEF\u5F84\u89C4\u5219**\uFF1A\u53EA\u7528\u76F8\u5BF9\u8DEF\u5F84\uFF1B\u7981\u6B62 `../`\uFF1B\u5DE5\u4F5C\u533A\u5916\u7684\u8DEF\u5F84\u4F1A\u88AB\u7CFB\u7EDF\u62D2\u7EDD\uFF08OS \u5C42\u5F3A\u5236\uFF0C\u975E\u8F6F\u7EA6\u675F\uFF09",
"- **\u751F\u6210\u9875\u9762\uFF08\u5FC5\u987B\u4EB2\u81EA\u5B8C\u6210\uFF09**\uFF1A\u5148 `load_skill` \u2192 `static-page-publish`\uFF0C\u518D\u7528 `write_file`/`edit_file` \u5199\u5165 `public/\u9875\u9762.html`",
"- **\u9ED8\u8BA4\u53EA\u751F\u6210 HTML**\uFF1A\u4E0D\u8981\u5728\u6CA1\u6709\u660E\u786E\u9700\u6C42\u65F6\u5F3A\u5236\u751F\u6210 Word\u3001PDF\u3001\u957F\u56FE\u7B49\u4F34\u751F\u6587\u4EF6",
"- **Word \u4E0B\u8F7D\u9875\uFF08\u6309\u9700\u4EB2\u81EA\u5B8C\u6210\uFF09**\uFF1A\u82E5\u7528\u6237\u660E\u786E\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 `load_skill` \u2192 `docx-generate`\uFF0C\u751F\u6210\u5E76\u786E\u8BA4 `public/*.docx` \u5DF2\u5B58\u5728\uFF0C\u518D\u5728 HTML \u4E2D\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5",
"- **\u957F\u56FE\u4E0B\u8F7D\uFF08\u6309\u9700\u4EB2\u81EA\u5B8C\u6210\uFF09**\uFF1A\u82E5\u7528\u6237\u660E\u786E\u8981\u6C42\u957F\u56FE\u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 `load_skill` \u2192 `long-image-download`\uFF0C\u8C03\u7528 `generate_long_image` \u751F\u6210\u5E76\u786E\u8BA4 `public/*.long.png` \u5DF2\u5B58\u5728",
"- **\u7528\u6237\u53EF\u89C1\u56DE\u590D**\uFF1A\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0 load_skill\u3001\u6280\u80FD\u66F4\u65B0\u3001\u9875\u811A\u6807\u8BB0\u3001mindspace-cover \u7B49\u5185\u90E8\u5B9E\u73B0\uFF1B\u5B8C\u6210\u540E\u76F4\u63A5\u7ED9\u51FA\u9875\u9762\u94FE\u63A5\u6216\u7ED3\u679C",
"- **\u7981\u6B62**\u7528 shell \u5199\u5165 HTML\uFF1B**\u7981\u6B62**\u8BA9\u7528\u6237\u300C\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u6216\u8BF4\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E write_file \u5DF2\u5931\u8D25\u5E76\u62A5\u544A\u9519\u8BEF",
"- \u5B8C\u6210\u540E\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 `[\u6807\u9898](URL)`\uFF1B\u5199\u5165 `public/\u9875\u9762.html` \u65F6 URL \u4E3A `.../MindSpace/<\u7528\u6237ID>/public/\u9875\u9762.html`",
"- \u82E5\u5148\u7528 `apps__create_app` \u8BBE\u8BA1/\u9884\u89C8\uFF0C\u6700\u540E\u4ECD\u8981\u628A\u5185\u5BB9 write_file \u843D\u5230 `public/\u9875\u9762.html` \u624D\u6709\u516C\u7F51\u94FE\u63A5",
"- **\u9700\u8981\u67E5\u5B9E\u65F6/\u771F\u5B9E\u4E16\u754C\u4FE1\u606F\u65F6**\uFF1A\u5148 `load_skill` \u2192 `web`\uFF0C\u4F18\u5148 `web_search`/`fetch_url`\uFF1B\u56FD\u5185 google.com \u4E0D\u53EF\u8FBE\uFF0C\u591A\u6B21\u65E0\u679C\u65F6\u6539\u8D70 Bing/360 \u7B49\u53EF\u8FBE\u641C\u7D22\u6E90",
"- **\u7981\u6B62**\u8BA9\u7528\u6237\u300C\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u6216\u8BF4\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E write_file \u5DF2\u5931\u8D25\u5E76\u62A5\u544A\u9519\u8BEF",
"- \u5B8C\u6210\u540E\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 `[\u6807\u9898](URL)`\uFF1B\u5199\u5165 `public/\u9875\u9762.html` \u65F6 URL \u4E3A `.../MindSpace/<\u7528\u6237ID>/public/\u9875\u9762.html`\uFF0C\u6309\u6A21\u677F\u62FC\u771F\u5B9E\u5730\u5740",
"- \u6309\u9700\u4E0B\u8F7D\u94FE\u63A5\uFF08\u5982 `report.docx`\uFF09\u5FC5\u987B\u4E0E HTML \u540C\u76EE\u5F55\u4E14\u6587\u4EF6\u540D\u4E00\u81F4\uFF1BWord \u5FC5\u987B\u7528 sandbox-fs `generate_docx` \u751F\u6210\uFF0C\u4E0D\u8981\u7528 `computercontroller` / shell \u4F5C\u4E3A\u4EA4\u4ED8\u4F9D\u636E",
"- \u6309\u9700\u957F\u56FE\u94FE\u63A5\uFF1A\u751F\u6210 `report.long.png` \u540E\u7ED9 `[\u957F\u56FE\u9884\u89C8](.../report.long.png)`\uFF0C\u4E0B\u8F7D\u7528 `[\u4E0B\u8F7D\u957F\u56FE](.../report.html?download=long-image)`",
`- \u53D1\u5E03\u6280\u80FD\uFF1A\`${PUBLISH_SKILL_NAME}\`\uFF08\u751F\u6210\u9875\u9762\u524D\u5E94 load_skill\uFF09`
].join("\n");
}
function ensureWorkspaceHintsInstalled(publishDir, context) {
const hintsPath = path5.join(publishDir, WORKSPACE_HINTS_FILENAME);
const legacyPath = path5.join(publishDir, LEGACY_WORKSPACE_HINTS_FILENAME);
const content = renderWorkspaceHints(context);
fs3.writeFileSync(hintsPath, content, "utf8");
if (fs3.existsSync(legacyPath)) {
fs3.unlinkSync(legacyPath);
}
return hintsPath;
}
function ensureDocxSkillInstalled(publishDir) {
if (!fs3.existsSync(DOCX_SKILL_DIR)) return null;
const skillRoot = path5.join(publishDir, ".agents", "skills", DOCX_SKILL_NAME);
fs3.rmSync(skillRoot, { recursive: true, force: true });
fs3.mkdirSync(path5.dirname(skillRoot), { recursive: true });
fs3.cpSync(DOCX_SKILL_DIR, skillRoot, { recursive: true });
return path5.join(skillRoot, "SKILL.md");
}
function ensureLongImageSkillInstalled(publishDir) {
if (!fs3.existsSync(LONG_IMAGE_SKILL_DIR)) return null;
const skillRoot = path5.join(publishDir, ".agents", "skills", LONG_IMAGE_SKILL_NAME);
fs3.rmSync(skillRoot, { recursive: true, force: true });
fs3.mkdirSync(path5.dirname(skillRoot), { recursive: true });
fs3.cpSync(LONG_IMAGE_SKILL_DIR, skillRoot, { recursive: true });
return path5.join(skillRoot, "SKILL.md");
}
function ensurePublishSkillInstalled(publishDir, context) {
ensureDocxSkillInstalled(publishDir);
ensureLongImageSkillInstalled(publishDir);
const skillRoot = path5.join(publishDir, ".agents", "skills", PUBLISH_SKILL_NAME);
fs3.mkdirSync(skillRoot, { recursive: true });
const skillPath = path5.join(skillRoot, "SKILL.md");
const content = renderPublishSkill(context);
const existing = fs3.existsSync(skillPath) ? fs3.readFileSync(skillPath, "utf8") : "";
if (existing !== content) {
fs3.writeFileSync(skillPath, content, "utf8");
}
return skillPath;
}
function ensureUserPublishLayout({
h5Root,
publicBaseUrl,
user,
legacyUsersRoot = null,
installWorkspaceHints = false
}) {
migrateUserPublishDir(h5Root, user, legacyUsersRoot);
const slug = resolvePublishKey(user);
const username = user.username ? resolveUsernameSlug(user) : slug;
const displayName = user.displayName ?? user.display_name ?? null;
const publishDir = resolvePublishDir(h5Root, user);
fs3.mkdirSync(path5.join(publishDir, "assets"), { recursive: true });
const context = { slug, username, displayName, publicBaseUrl, publishDir };
if (installWorkspaceHints) {
ensureWorkspaceHintsInstalled(publishDir, context);
ensurePublishSkillInstalled(publishDir, context);
}
return {
slug,
username,
displayName,
publishDir,
publicBaseUrl,
publicUrl: buildPublicUrl(publicBaseUrl, slug),
skillName: PUBLISH_SKILL_NAME,
constraints: buildPublishConstraints(context)
};
}
// user-memory-profile.mjs
import fs4 from "node:fs";
import path6 from "node:path";
var USER_MEMORY_PROFILE_FILENAME = ".tkmind-profile.json";
var USER_MEMORY_PROFILE_VERSION = 1;
var DEFAULT_TIMEZONE = "Asia/Shanghai";
function buildInitialUserMemoryProfile({
userId,
displayName,
username,
slug,
now = Date.now()
}) {
const addressName = resolveUserAddressName({ displayName, username, slug });
return {
version: USER_MEMORY_PROFILE_VERSION,
userId,
displayName: addressName,
language: "zh-CN",
responseStyle: "balanced",
preferences: [],
createdAt: now,
updatedAt: now
};
}
function resolveUserMemoryProfilePath(workspaceRoot) {
return path6.join(workspaceRoot, USER_MEMORY_PROFILE_FILENAME);
}
function loadUserMemoryProfile(workspaceRoot) {
const profilePath = resolveUserMemoryProfilePath(workspaceRoot);
if (!fs4.existsSync(profilePath)) return null;
try {
const raw = fs4.readFileSync(profilePath, "utf8");
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return null;
return parsed;
} catch {
return null;
}
}
function ensureUserMemoryProfile(workspaceRoot, context) {
const profilePath = resolveUserMemoryProfilePath(workspaceRoot);
const existing = loadUserMemoryProfile(workspaceRoot);
if (existing) {
const addressName = resolveUserAddressName(context);
const nextDisplayName = addressName || existing.displayName;
if (nextDisplayName && nextDisplayName !== existing.displayName) {
const updated = {
...existing,
displayName: nextDisplayName,
updatedAt: Date.now()
};
fs4.writeFileSync(profilePath, `${JSON.stringify(updated, null, 2)}
`, "utf8");
return updated;
}
return existing;
}
const profile = buildInitialUserMemoryProfile({
userId: context.userId,
displayName: context.displayName,
username: context.username,
slug: context.slug
});
fs4.writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}
`, "utf8");
return profile;
}
function renderMemoryStoreGuidance({ addressName }) {
const name = addressName || "\u7528\u6237";
return [
"## TKMind \u957F\u671F\u8BB0\u5FC6\uFF08L3 / memory \u6269\u5C55\uFF09",
"",
`- \u5F53\u524D\u670D\u52A1\u5BF9\u8C61\uFF1A**${name}**\uFF08\u4EC5\u6B64\u7528\u6237\uFF0C\u4E0D\u5F97\u4E0E\u5176\u4ED6\u7528\u6237\u6DF7\u6DC6\uFF09`,
"- \u4F7F\u7528 memory \u5DE5\u5177\u8BB0\u5F55\u7528\u6237**\u660E\u786E\u8868\u8FBE**\u7684\u504F\u597D\u3001\u4E60\u60EF\u4E0E\u7A33\u5B9A\u4E8B\u5B9E",
"- \u8BB0\u5FC6\u5206\u7C7B\u5EFA\u8BAE\uFF1A",
" - `preference`\uFF1A\u56DE\u590D\u98CE\u683C\u3001\u683C\u5F0F\u3001\u8BED\u8A00\u3001\u79F0\u547C\u4E60\u60EF",
" - `project`\uFF1A\u5F53\u524D\u9879\u76EE\u76EE\u6807\u3001\u7EA6\u5B9A\u3001\u6280\u672F\u9009\u578B\uFF08\u4E0E L2 \u9879\u76EE\u8BB0\u5FC6\u4E92\u8865\uFF09",
" - `fact`\uFF1A\u957F\u671F\u7A33\u5B9A\u3001\u53EF\u590D\u7528\u7684\u4E8B\u5B9E\uFF08\u5982\u90E8\u95E8\u3001\u5E38\u7528\u5DE5\u5177\uFF09",
"- \u7528\u6237\u8BF4\u300C\u5FD8\u8BB0\u2026\u300D\u300C\u4E0D\u8981\u518D\u2026\u300D\u300C\u53D6\u6D88\u504F\u597D\u2026\u300D\u65F6\uFF0C\u5FC5\u987B\u66F4\u65B0\u6216\u5220\u9664\u5BF9\u5E94\u8BB0\u5FC6",
"- **\u7981\u6B62**\u8BB0\u5F55\u5176\u4ED6\u7528\u6237\u7684\u4FE1\u606F\uFF1B**\u7981\u6B62**\u731C\u6D4B\u672A\u660E\u786E\u8868\u8FBE\u7684\u504F\u597D",
"- \u4E0E L2 \u9879\u76EE\u8BB0\u5FC6\uFF08harness\uFF09\u5206\u5DE5\uFF1AL2 \u8BB0\u8FD1\u671F\u5DE5\u4F5C\u4E0E\u51B3\u7B56\u6458\u8981\uFF1BL3 \u8BB0\u53EF\u8DE8\u4F1A\u8BDD\u590D\u7528\u7684\u4E2A\u4EBA\u504F\u597D"
].join("\n");
}
function renderPreferenceLines(profile) {
const items = Array.isArray(profile?.preferences) ? profile.preferences : [];
if (items.length === 0) {
return ["- \uFF08\u6682\u65E0\u7ED3\u6784\u5316\u504F\u597D \u2014 \u53EF\u5728\u5BF9\u8BDD\u4E2D\u7528 memory \u5DE5\u5177\u8865\u5145\uFF09"];
}
return items.map((item) => {
if (typeof item === "string") return `- ${item}`;
const category = item?.category ? `[${item.category}] ` : "";
const label = item?.label ?? item?.key ?? "preference";
const value = item?.value ?? item?.content ?? "";
return `- ${category}${label}\uFF1A${value}`;
});
}
function renderUserMemoryProfileForHarness(profile, context = {}) {
const addressName = resolveUserAddressName({
displayName: profile?.displayName ?? context.displayName,
username: context.username,
slug: context.slug
});
const language = profile?.language === "zh-CN" ? "\u7B80\u4F53\u4E2D\u6587" : profile?.language ?? "\u7B80\u4F53\u4E2D\u6587";
const styleMap = {
concise: "\u7B80\u6D01\u76F4\u63A5\uFF0C\u5C11\u5E9F\u8BDD",
detailed: "\u8BE6\u7EC6\u5B8C\u6574\uFF0C\u5FC5\u8981\u65F6\u5C55\u5F00",
balanced: "\u7B80\u6D01\u52A1\u5B9E\uFF0C\u5FC5\u8981\u65F6\u8865\u5145\u7EC6\u8282"
};
const responseStyle = styleMap[profile?.responseStyle] ?? styleMap.balanced;
return [
"## TKMind \u7528\u6237\u504F\u597D\u753B\u50CF\uFF08L3\uFF09",
"",
`- \u7528\u6237\u79F0\u547C\uFF1A**${addressName}**`,
`- \u754C\u9762\u8BED\u8A00\uFF1A${language}`,
`- \u9ED8\u8BA4\u56DE\u590D\u98CE\u683C\uFF1A${responseStyle}`,
"",
"### \u5DF2\u8BB0\u5F55\u7684\u7ED3\u6784\u5316\u504F\u597D",
...renderPreferenceLines(profile)
].join("\n");
}
function renderStoredUserMemoryLines(memories) {
const items = Array.isArray(memories) ? memories : [];
if (items.length === 0) return ["- \uFF08\u6682\u65E0\u5DF2\u6C89\u6DC0\u7684\u5BF9\u8BDD\u8BB0\u5FC6\uFF09"];
return items.slice(0, 50).map((item) => {
const label = item?.label ? `[${item.label}] ` : "";
const text = String(item?.text ?? item?.memory_text ?? item?.memoryText ?? "").trim();
return `- ${label}${text}`;
}).filter((line) => line.trim() !== "-");
}
function renderStoredUserMemoriesForHarness(memories) {
return [
"## TKMind \u5DF2\u6C89\u6DC0\u7528\u6237\u8BB0\u5FC6",
"",
"\u4EE5\u4E0B\u5185\u5BB9\u6765\u81EA\u7528\u6237\u5386\u53F2\u5BF9\u8BDD\u7684\u957F\u671F\u8BB0\u5FC6\u62BD\u53D6\uFF0C\u4EC5\u7528\u4E8E\u6539\u5584\u5F53\u524D\u7528\u6237\u4F53\u9A8C\u3002",
"\u4E0D\u8981\u628A\u8FD9\u4E9B\u5185\u5BB9\u900F\u9732\u7ED9\u5176\u4ED6\u7528\u6237\uFF1B\u5982\u679C\u7528\u6237\u8981\u6C42\u5FD8\u8BB0\u6216\u5220\u9664\uFF0C\u5E94\u9075\u4ECE\u5E76\u66F4\u65B0\u5BF9\u5E94\u8BB0\u5FC6\u3002",
"",
...renderStoredUserMemoryLines(memories)
].join("\n");
}
function formatDateParts(now, timezone) {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit"
});
const parts = Object.fromEntries(
formatter.formatToParts(new Date(now)).map((part) => [part.type, part.value])
);
return {
year: parts.year ?? "0000",
month: parts.month ?? "01",
day: parts.day ?? "01"
};
}
function formatLocalClockParts(now, timezone) {
const formatter = new Intl.DateTimeFormat("en-GB", {
timeZone: timezone,
hour: "2-digit",
minute: "2-digit",
hour12: false
});
const parts = Object.fromEntries(
formatter.formatToParts(new Date(now)).map((part) => [part.type, part.value])
);
return {
hour: Number(parts.hour ?? 0),
minute: Number(parts.minute ?? 0)
};
}
function resolveTimeOfDayPeriod(hour) {
const h = Number(hour);
if (h < 5) return { period: "\u51CC\u6668", greeting: "\u4F60\u597D" };
if (h < 9) return { period: "\u65E9\u4E0A", greeting: "\u65E9\u4E0A\u597D" };
if (h < 12) return { period: "\u4E0A\u5348", greeting: "\u4E0A\u5348\u597D" };
if (h < 14) return { period: "\u4E2D\u5348", greeting: "\u4E2D\u5348\u597D" };
if (h < 18) return { period: "\u4E0B\u5348", greeting: "\u4E0B\u5348\u597D" };
return { period: "\u665A\u4E0A", greeting: "\u665A\u4E0A\u597D" };
}
function buildCurrentTimeAgentPrefix({ now = Date.now(), timezone = DEFAULT_TIMEZONE } = {}) {
return [
"\u3010TKMind \u65F6\u95F4\u57FA\u51C6 - \u4EC5\u4F9B\u56DE\u7B54\u65F6\u95F4/\u65E5\u671F/\u65F6\u6BB5\u95EE\u5019\uFF0C\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0\u3011",
renderCurrentTimeAnchor({ now, timezone }),
""
].join("\n");
}
function renderCurrentTimeAnchor({ now = Date.now(), timezone = DEFAULT_TIMEZONE } = {}) {
const { year, month, day } = formatDateParts(now, timezone);
const { hour, minute } = formatLocalClockParts(now, timezone);
const { period, greeting } = resolveTimeOfDayPeriod(hour);
const clock = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
const weekday = new Intl.DateTimeFormat("zh-CN", {
timeZone: timezone,
weekday: "long"
}).format(new Date(now));
return [
"## TKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6",
"",
`- \u5F53\u524D\u65F6\u533A\uFF1A${timezone}`,
`- \u5F53\u524D\u65E5\u671F\uFF1A${year}-${month}-${day}\uFF08${weekday}\uFF09`,
`- \u5F53\u524D\u65F6\u523B\uFF1A${clock}\uFF08${period}\uFF09`,
`- \u82E5\u9700\u65F6\u6BB5\u95EE\u5019\u53EF\u53C2\u8003\uFF1A${greeting}\uFF08\u4EC5\u65B0\u4F1A\u8BDD\u5F00\u573A\u6216\u7528\u6237\u4E3B\u52A8\u6253\u62DB\u547C\u65F6\u4F7F\u7528\uFF0C\u666E\u901A\u4EFB\u52A1\u56DE\u590D\u4E0D\u8981\u6BCF\u6761\u90FD\u52A0\uFF09`,
"- \u56DE\u7B54\u4E2D\u6D89\u53CA\u201C\u4ECA\u5929 / \u660E\u5929 / \u540E\u5929 / \u5468\u51E0 / \u65E9\u4E0A / \u4E0B\u5348 / \u665A\u4E0A\u201D\u7B49\u65F6\u95F4\u8868\u8FF0\u65F6\uFF0C\u5FC5\u987B\u4EE5\u4E0A\u8FF0\u65E5\u671F\u4E0E\u65F6\u523B\u4E3A\u51C6\uFF0C\u7981\u6B62\u81EA\u884C\u5047\u8BBE\u5F53\u524D\u65F6\u95F4\u6216\u4F7F\u7528 UTC \u7B49\u5176\u5B83\u65F6\u533A\u3002"
].join("\n");
}
function buildSessionMemoryEntries({
workingDir,
sessionPolicy,
sandboxConstraints = null,
userContext = null,
userMemories = null,
now = Date.now()
}) {
const entries = [];
const timezone = String(
userContext?.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? DEFAULT_TIMEZONE
).trim() || DEFAULT_TIMEZONE;
if (sandboxConstraints?.trim()) {
entries.push({
title: "TKMind \u7528\u6237\u7A7A\u95F4\u6C99\u7BB1",
content: sandboxConstraints.trim()
});
}
const executorGuidance = renderCodeExecutorGuidance(sessionPolicy);
if (executorGuidance) {
entries.push({
title: "TKMind \u4EE3\u7801\u59D4\u6258\u7B56\u7565",
content: executorGuidance
});
}
entries.push({
title: "TKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6",
content: renderCurrentTimeAnchor({ now, timezone })
});
const scheduleTools = (sessionPolicy?.extensionOverrides ?? []).find((ext) => ext.name === "sandbox-fs");
if ((scheduleTools?.available_tools ?? []).includes("schedule_create_item")) {
entries.push({
title: "TKMind \u65E5\u7A0B\u5199\u5165\u89C4\u5219",
content: [
"\u521B\u5EFA\u6216\u63D0\u9192\u5F85\u529E/\u65E5\u7A0B\u65F6\uFF1A",
"- \u5FC5\u987B\u4F7F\u7528 startLocal / endLocal / remindLocal\uFF0C\u683C\u5F0F YYYY-MM-DD HH:mm\uFF08\u7528\u6237\u65F6\u533A\u5899\u4E0A\u65F6\u949F\uFF09\u3002",
"- \u7981\u6B62\u81EA\u884C\u4F30\u7B97 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF1B\u4F20\u9519\u4F1A\u88AB\u5DE5\u5177\u62D2\u7EDD\u3002",
"- \u5199\u5165\u540E\u8C03\u7528 schedule_list_items \u6838\u5BF9\u65E5\u671F\u662F\u5426\u4E0E\u7528\u6237\u8868\u8FF0\u4E00\u81F4\u3002"
].join("\n")
});
}
if (!hasMemoryStore(sessionPolicy)) {
return entries;
}
const profile = userContext?.userId ? ensureUserMemoryProfile(workingDir, userContext) : loadUserMemoryProfile(workingDir);
const addressName = resolveUserAddressName({
displayName: profile?.displayName ?? userContext?.displayName,
username: userContext?.username,
slug: userContext?.slug
});
entries.push({
title: "TKMind \u957F\u671F\u8BB0\u5FC6\u89C4\u5219",
content: renderMemoryStoreGuidance({ addressName })
});
if (profile) {
entries.push({
title: "TKMind \u7528\u6237\u504F\u597D\u753B\u50CF",
content: renderUserMemoryProfileForHarness(profile, userContext ?? {})
});
}
if (Array.isArray(userMemories) && userMemories.length > 0) {
entries.push({
title: "TKMind \u5DF2\u6C89\u6DC0\u7528\u6237\u8BB0\u5FC6",
content: renderStoredUserMemoriesForHarness(userMemories)
});
}
return entries;
}
function hasMemoryStore(sessionPolicy) {
return (sessionPolicy?.extensionOverrides ?? []).some((ext) => ext.name === "memory");
}
function availableDelegateExecutors(sessionPolicy) {
const names = new Set((sessionPolicy?.extensionOverrides ?? []).map((ext) => ext.name));
return ["aider", "openhands"].filter((name) => names.has(name));
}
function resolveCodeExecutorRouting(sessionPolicy) {
const available = availableDelegateExecutors(sessionPolicy);
const configuredPreferred = String(
sessionPolicy?.policies?.code_delegate_executor ?? "auto"
).trim();
const routing = String(sessionPolicy?.policies?.code_task_routing ?? "balanced").trim();
const preferred = (configuredPreferred === "aider" || configuredPreferred === "openhands") && available.includes(configuredPreferred) ? configuredPreferred : "auto";
const rules = [];
if (routing === "split") {
rules.push("\u5C0F\u8303\u56F4\u8865\u4E01\u3001\u5C40\u90E8\u4FEE\u590D\u3001\u5C11\u6587\u4EF6\u4FEE\u6539\u4F18\u5148 `aider`\u3002");
rules.push("\u590D\u6742\u591A\u6587\u4EF6\u6539\u9020\u3001\u4ED3\u5E93\u63A2\u7D22\u3001\u8F83\u91CD\u7684\u547D\u4EE4\u6267\u884C\u4F18\u5148 `openhands`\u3002");
} else if (routing === "force_aider") {
rules.push("\u53EA\u8981\u4EFB\u52A1\u80FD\u7531 `aider` \u80DC\u4EFB\uFF0C\u5C31\u4F18\u5148\u7EDF\u4E00\u8D70 `aider`\u3002");
rules.push("\u53EA\u6709 `aider` \u660E\u663E\u65E0\u6CD5\u80DC\u4EFB\u65F6\u624D\u56DE\u9000\u5230 `openhands`\u3002");
} else if (routing === "force_openhands") {
rules.push("\u53EA\u8981\u4EFB\u52A1\u9700\u8981\u4EE3\u7801\u59D4\u6258\uFF0C\u5C31\u4F18\u5148\u7EDF\u4E00\u8D70 `openhands`\u3002");
rules.push("\u53EA\u6709\u4EFB\u52A1\u660E\u663E\u66F4\u9002\u5408\u8F7B\u91CF\u8865\u4E01\u65F6\u624D\u56DE\u9000\u5230 `aider`\u3002");
} else {
rules.push("\u7531 Goose \u6839\u636E\u4EFB\u52A1\u590D\u6742\u5EA6\u3001\u6D89\u53CA\u6587\u4EF6\u6570\u3001\u662F\u5426\u9700\u8981\u4ED3\u5E93\u63A2\u7D22\u548C\u547D\u4EE4\u6267\u884C\uFF0C\u5728 `aider` \u4E0E `openhands` \u4E4B\u95F4\u5E73\u8861\u9009\u62E9\u3002");
}
if (preferred !== "auto") {
rules.unshift(`\u540E\u53F0\u4F18\u5148\u6267\u884C\u5668\uFF1A**${preferred}**\u3002`);
} else {
rules.unshift("\u540E\u53F0\u4F18\u5148\u6267\u884C\u5668\uFF1A`auto`\uFF0C\u7531 Goose \u7ED3\u5408\u4EFB\u52A1\u7279\u5F81\u51B3\u5B9A\u3002");
}
return {
available,
preferred,
routing,
rules
};
}
function containsAny(text, patterns) {
return patterns.some((pattern) => pattern.test(text));
}
function suggestCodeExecutorForTask(taskText, sessionPolicy) {
const text = String(taskText ?? "").trim().toLowerCase();
if (!text) return null;
const routing = resolveCodeExecutorRouting(sessionPolicy);
if (routing.available.length === 0) return null;
const codingSignals = [
/bug|fix|debug|refactor|feature|repo|repository|code|patch|test|compile|build/,
/修复|改代码|重构|功能|仓库|代码|补丁|测试|编译|构建|多文件|命令|脚本/
];
if (!containsAny(text, codingSignals)) {
return null;
}
let aiderScore = 0;
let openhandsScore = 0;
if (containsAny(text, [/small|minor|tiny|simple|one file|single file|quick patch/, /小改|微调|简单修复|单文件|一个文件|快速修复/])) {
aiderScore += 2;
}
if (containsAny(text, [/refactor|multi-?file|repo|repository|end-to-end|investigate|explore/, /重构|多文件|仓库级|全链路|排查|探索代码库/])) {
openhandsScore += 2;
}
if (containsAny(text, [/run|command|terminal|shell|build|compile|test suite/, /执行命令|终端|shell|构建|编译|整套测试/])) {
openhandsScore += 1;
}
if (containsAny(text, [/rename|edit|patch|tweak/, /修改一下|补丁|小范围调整|局部编辑/])) {
aiderScore += 1;
}
if (routing.routing === "split") {
aiderScore += 1;
openhandsScore += 1;
} else if (routing.routing === "force_aider") {
aiderScore += 3;
} else if (routing.routing === "force_openhands") {
openhandsScore += 3;
}
if (routing.preferred === "aider") aiderScore += 2;
if (routing.preferred === "openhands") openhandsScore += 2;
let suggested = null;
if (openhandsScore > aiderScore && routing.available.includes("openhands")) {
suggested = "openhands";
} else if (aiderScore > openhandsScore && routing.available.includes("aider")) {
suggested = "aider";
} else if (routing.preferred !== "auto" && routing.available.includes(routing.preferred)) {
suggested = routing.preferred;
} else if (routing.routing === "split" && routing.available.includes("aider") && routing.available.includes("openhands")) {
suggested = openhandsScore >= aiderScore ? "openhands" : "aider";
} else {
suggested = routing.available[0] ?? null;
}
if (!suggested) return null;
const reason = suggested === "openhands" ? "\u4EFB\u52A1\u770B\u8D77\u6765\u66F4\u50CF\u590D\u6742\u591A\u6587\u4EF6\u6539\u9020\u3001\u4ED3\u5E93\u63A2\u7D22\u6216\u9700\u8981\u66F4\u591A\u547D\u4EE4\u6267\u884C\u3002" : "\u4EFB\u52A1\u770B\u8D77\u6765\u66F4\u50CF\u5C40\u90E8\u8865\u4E01\u3001\u5C0F\u8303\u56F4\u4FEE\u590D\u6216\u8F83\u8F7B\u91CF\u7684\u4EE3\u7801\u4FEE\u6539\u3002";
return {
suggestedExecutor: suggested,
reason,
routing
};
}
function buildTaskRoutingAgentText(taskText, sessionPolicy) {
const suggestion = suggestCodeExecutorForTask(taskText, sessionPolicy);
if (!suggestion) return String(taskText ?? "").trim();
return [
"\u3010TKMind \u8DEF\u7531\u63D0\u793A\u3011\u4EE5\u4E0B\u63D0\u793A\u4EC5\u7528\u4E8E\u6267\u884C\u5668\u7F16\u6392\uFF0C\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0\u3002",
`\u5F53\u524D\u4EE3\u7801\u4EFB\u52A1\u5EFA\u8BAE\u4F18\u5148\u59D4\u6258\u7ED9\uFF1A${suggestion.suggestedExecutor}\u3002`,
`\u539F\u56E0\uFF1A${suggestion.reason}`,
"\u82E5\u9996\u9009\u6267\u884C\u5668\u5F53\u524D\u4E0D\u53EF\u7528\u6216\u660E\u663E\u4E0D\u9002\u5408\uFF0C\u53EF\u56DE\u9000\u5230\u53E6\u4E00\u4E2A\u5DF2\u6388\u6743\u6267\u884C\u5668\uFF0C\u5E76\u5728\u6700\u7EC8\u56DE\u590D\u91CC\u7B80\u8FF0\u539F\u56E0\u3002",
"",
String(taskText ?? "").trim()
].join("\n");
}
function renderCodeExecutorGuidance(sessionPolicy) {
const { available, preferred, routing, rules } = resolveCodeExecutorRouting(sessionPolicy);
if (available.length === 0) return "";
const availableText = available.join(" / ");
const lines = [
"## TKMind \u4EE3\u7801\u59D4\u6258\u6267\u884C\u5668\u8DEF\u7531",
"",
`- \u5F53\u524D\u53EF\u7528\u7684\u4EE3\u7801\u59D4\u6258\u6267\u884C\u5668\uFF1A${availableText}`
];
if (preferred === "aider" || preferred === "openhands") {
lines.push(`- \u540E\u53F0\u7B56\u7565\u8981\u6C42\uFF1A\u591A\u6587\u4EF6\u7F16\u7801\u4EFB\u52A1\u4F18\u5148\u4F7F\u7528 **${preferred}**\u3002`);
lines.push(
"- \u5982\u679C\u9996\u9009\u6267\u884C\u5668\u5F53\u524D\u4E0D\u53EF\u7528\u3001\u65E0\u6CD5\u5B8C\u6210\u4EFB\u52A1\u3001\u6216\u4EFB\u52A1\u660E\u663E\u66F4\u9002\u5408\u53E6\u4E00\u6267\u884C\u5668\uFF0C\u53EF\u56DE\u9000\u5230\u53E6\u4E00\u4E2A\u5DF2\u6388\u6743\u6267\u884C\u5668\uFF0C\u5E76\u5728\u56DE\u590D\u4E2D\u8BF4\u660E\u539F\u56E0\u3002"
);
} else {
lines.push("- \u540E\u53F0\u7B56\u7565\u8981\u6C42\uFF1A\u7531 Goose \u6839\u636E\u4EFB\u52A1\u590D\u6742\u5EA6\u5728\u5DF2\u6388\u6743\u6267\u884C\u5668\u4E2D\u81EA\u52A8\u9009\u62E9\u3002");
}
lines.push(`- \u4EFB\u52A1\u8DEF\u7531\u6A21\u5F0F\uFF1A\`${routing}\``);
for (const rule of rules) {
lines.push(`- ${rule}`);
}
lines.push("- \u82E5\u5F53\u524D\u4EFB\u52A1\u4E0D\u9700\u8981\u59D4\u6258\u7F16\u7801\u6267\u884C\u5668\uFF0C\u53EF\u7EE7\u7EED\u76F4\u63A5\u4F7F\u7528 Goose \u81EA\u8EAB\u5DE5\u5177\u5B8C\u6210\u3002");
return lines.join("\n");
}
// session-reconcile.mjs
import path7 from "node:path";
function extensionName(config) {
return config?.name ?? null;
}
function allowedExtensionNames(extensionOverrides) {
if (!extensionOverrides) return null;
return new Set(extensionOverrides.map((item) => item.name).filter(Boolean));
}
function extensionToolsKey(config) {
const tools = config?.available_tools ?? config?.availableTools ?? [];
return [...tools].sort().join("\0");
}
function extensionConfigsMatch(sessionExt, desiredConfig) {
if (extensionName(sessionExt) !== extensionName(desiredConfig)) return false;
return extensionToolsKey(sessionExt) === extensionToolsKey(desiredConfig);
}
function extensionsNeedingRefresh(currentExtensions, desiredExtensions) {
const current = currentExtensions ?? [];
const desired = desiredExtensions ?? [];
const toRemove = [];
const toAdd = [];
for (const ext of current) {
const name = extensionName(ext);
if (!name) continue;
const wanted = desired.find((item) => extensionName(item) === name);
if (!wanted) {
toRemove.push(name);
continue;
}
if (!extensionConfigsMatch(ext, wanted)) {
toRemove.push(name);
toAdd.push(wanted);
}
}
for (const config of desired) {
const name = extensionName(config);
if (!name) continue;
const exists = current.some((ext) => extensionName(ext) === name);
if (!exists) {
toAdd.push(config);
}
}
return { toRemove, toAdd };
}
function samePath(left, right) {
if (!left || !right) return false;
return path7.resolve(left) === path7.resolve(right);
}
async function readJson(upstream) {
const text = await upstream.text();
if (!upstream.ok) {
throw new Error(text || `upstream ${upstream.status}`);
}
return text ? JSON.parse(text) : null;
}
function isInvalidDirectoryPathError(err) {
return err instanceof Error && /Invalid directory path/i.test(err.message);
}
async function reconcileAgentSession(apiFetch2, sessionId, {
workingDir,
sessionPolicy,
sandboxConstraints = null,
userContext = null,
userMemories = null,
tolerateInvalidWorkingDir = false
}) {
if (sessionPolicy?.unrestricted) return;
const session = await readJson(await apiFetch2(`/sessions/${sessionId}`));
let needsRestart = false;
if (workingDir && !samePath(session?.working_dir, workingDir)) {
try {
await readJson(
await apiFetch2("/agent/update_working_dir", {
method: "POST",
body: JSON.stringify({ session_id: sessionId, working_dir: workingDir })
})
);
} catch (err) {
if (!(tolerateInvalidWorkingDir && isInvalidDirectoryPathError(err))) {
throw err;
}
console.warn(
`[session-reconcile] skip invalid working_dir during resume for session ${sessionId}: ${err.message}`
);
}
}
if (sessionPolicy?.gooseMode && session?.goose_mode !== sessionPolicy.gooseMode) {
await readJson(
await apiFetch2("/agent/update_session", {
method: "POST",
body: JSON.stringify({
session_id: sessionId,
goose_mode: sessionPolicy.gooseMode
})
})
);
needsRestart = true;
}
const desired = sessionPolicy?.extensionOverrides ?? [];
const allowed = allowedExtensionNames(desired);
const currentPayload = await readJson(await apiFetch2(`/sessions/${sessionId}/extensions`));
const current = currentPayload?.extensions ?? [];
let removedAny = false;
for (const ext of current) {
const name = extensionName(ext);
if (!name || allowed.has(name)) continue;
await readJson(
await apiFetch2("/agent/remove_extension", {
method: "POST",
body: JSON.stringify({ session_id: sessionId, name })
})
);
removedAny = true;
}
const refreshed = removedAny ? (await readJson(await apiFetch2(`/sessions/${sessionId}/extensions`)))?.extensions ?? [] : current;
const { toRemove, toAdd } = extensionsNeedingRefresh(refreshed, desired);
for (const name of toRemove) {
await readJson(
await apiFetch2("/agent/remove_extension", {
method: "POST",
body: JSON.stringify({ session_id: sessionId, name })
})
);
needsRestart = true;
}
for (const config of toAdd) {
await readJson(
await apiFetch2("/agent/add_extension", {
method: "POST",
body: JSON.stringify({ session_id: sessionId, config })
})
);
needsRestart = true;
}
if (needsRestart) {
await readJson(
await apiFetch2("/agent/restart", {
method: "POST",
body: JSON.stringify({ session_id: sessionId })
})
);
}
const sandboxText = sandboxConstraints?.trim() ? buildSandboxSessionConstraints({
baseConstraints: sandboxConstraints,
developerTools: developerToolsFromPolicy(sessionPolicy)
}) : null;
const memoryEntries = buildSessionMemoryEntries({
workingDir,
sessionPolicy,
sandboxConstraints: sandboxText,
userContext,
userMemories
});
if (memoryEntries.length > 0) {
for (const entry of memoryEntries) {
if (!entry.content?.trim()) continue;
await apiFetch2("/agent/harness_remember", {
method: "POST",
body: JSON.stringify({
sessionId,
content: entry.content,
title: entry.title
})
});
}
await apiFetch2("/agent/harness_bootstrap", {
method: "POST",
body: JSON.stringify({
sessionId,
force: true
})
});
}
}
// imgproxy-signer.mjs
import crypto4 from "node:crypto";
function createImgproxySigner(key, salt) {
const keyBuf = Buffer.from(key, "hex");
const saltBuf = Buffer.from(salt, "hex");
function signPath(path13) {
const signaturePayload = Buffer.concat([saltBuf, Buffer.from(path13)]);
const signature = crypto4.createHmac("sha256", keyBuf).update(signaturePayload).digest();
const signatureB64 = signature.toString("base64url");
return `/${signatureB64}${path13}`;
}
function buildUrl(baseUrl, storagePath, preset = "display") {
if (!baseUrl || !storagePath) {
throw new Error("baseUrl and storagePath are required");
}
let width, height, quality, format;
switch (preset) {
case "vision":
width = 768;
height = 768;
quality = 60;
format = "jpg";
break;
case "thumb":
width = 256;
height = 256;
quality = 70;
format = "jpg";
break;
case "display":
default:
width = 1280;
height = 1280;
quality = 85;
format = "jpg";
break;
}
const unsignedPath = `/rs:fit:${width}:${height}:0/q:${quality}/plain/local:///${storagePath}@${format}`;
const signedPath = signPath(unsignedPath);
const baseUrlNormalized = String(baseUrl).replace(/\/$/, "");
return `${baseUrlNormalized}${signedPath}`;
}
return { buildUrl, signPath };
}
// tkmind-proxy.mjs
var insecureDispatcher2 = new Agent2({
connect: { rejectUnauthorized: false }
});
function isHttpsTarget(target) {
return target.startsWith("https://");
}
function sanitizeUserFacingProxyMessage(message, fallback = "\u540E\u7AEF\u8FDE\u63A5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5") {
const normalized = String(message ?? "").trim();
if (!normalized) return fallback;
if (!/goose|goosed/i.test(normalized)) return normalized;
if (/超时|timeout/i.test(normalized)) {
return "\u540E\u7AEF\u8FDE\u63A5\u8D85\u65F6\uFF0C\u8BF7\u786E\u8BA4\u540E\u7AEF\u670D\u52A1\u6B63\u5E38\u540E\u91CD\u8BD5";
}
if (/不可用|连接失败|failed to fetch|networkerror|fetch failed|upstream|econn|enotfound/i.test(normalized)) {
return fallback;
}
return normalized.replace(/\bgoosed\b/gi, "\u540E\u7AEF\u670D\u52A1").replace(/\bgoose\b/gi, "\u540E\u7AEF");
}
var PUBLIC_IMAGE_STORAGE_KEY_PATTERN = /^users\/([^/]+)\/images\/(\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)$/i;
function extractPublicImageStorageKey(rawUrl) {
const value = String(rawUrl ?? "").trim();
if (!value) return null;
const directMatch = value.match(/(^|\/)(users\/[^?#"'<>@\s]+\/images\/\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)/i);
if (directMatch?.[2]) return directMatch[2];
const plainIndex = value.indexOf("plain/local:///");
if (plainIndex >= 0) {
const tail = value.slice(plainIndex + "plain/local:///".length);
const storageKey = tail.replace(/@[a-z0-9]+(?:[?#].*)?$/i, "").replace(/[?#].*$/, "");
return storageKey || null;
}
return null;
}
function buildPublicStandardImageUrl(rawUrl, userId) {
const storageKey = extractPublicImageStorageKey(rawUrl);
if (!storageKey) return null;
const match = storageKey.match(PUBLIC_IMAGE_STORAGE_KEY_PATTERN);
if (!match || match[1] !== String(userId ?? "")) return null;
return buildPublicUrl(resolvePublicBaseUrl(), userId, `public/images/${match[2]}`);
}
async function apiFetch(target, apiSecret, pathname, init = {}) {
const url = new URL(pathname, target);
const headers = {
...init.headers ?? {},
"X-Secret-Key": apiSecret
};
if (init.body && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
return undiciFetch2(url, {
...init,
headers,
dispatcher: isHttpsTarget(target) ? insecureDispatcher2 : void 0
});
}
function createRuntimeRouter({
targets,
targetHealthy,
redisUrl = process.env.MEMIND_RUNTIME_REDIS_URL,
namespace = process.env.MEMIND_RUNTIME_REDIS_NAMESPACE ?? "memind:runtime"
}) {
if (!redisUrl || targets.length <= 1) return null;
let clientPromise = null;
let warned = false;
const workerIdForTarget = (target) => {
const idx = targets.indexOf(target);
return idx >= 0 ? `goosed-${idx + 1}` : `goosed-${Buffer.from(String(target)).toString("base64url")}`;
};
const key = (...parts) => [namespace, ...parts].join(":");
const getClient = async () => {
if (!clientPromise) {
clientPromise = import("redis").then(async ({ createClient }) => {
const client = createClient({ url: redisUrl });
client.on("error", (err) => {
if (!warned) {
warned = true;
console.warn("[RuntimeRouter] Redis error:", err instanceof Error ? err.message : err);
}
});
await client.connect();
console.log("[RuntimeRouter] Redis scheduler enabled");
return client;
}).catch((err) => {
clientPromise = null;
if (!warned) {
warned = true;
console.warn("[RuntimeRouter] Redis disabled:", err instanceof Error ? err.message : err);
}
return null;
});
}
return clientPromise;
};
const readNumber = (value) => {
const n = Number(value ?? 0);
return Number.isFinite(n) ? n : 0;
};
const parseFirstTokenSample = (value) => {
const parts = String(value ?? "").split(":");
return readNumber(parts[1]);
};
const percentile = (values, pct) => {
const sorted = values.map((value) => readNumber(value)).filter((value) => value > 0).sort((a, b) => a - b);
if (sorted.length === 0) return 0;
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(pct / 100 * sorted.length) - 1));
return sorted[idx];
};
const firstTokenWindowStats = async (client, workerId, windowMs) => {
if (!client) return { count: 0, p50Ms: 0, p95Ms: 0 };
const now = Date.now();
const samples = await client.sendCommand([
"ZRANGEBYSCORE",
key("worker", workerId, "first_token_samples"),
String(now - windowMs),
String(now)
]).catch(() => []);
const values = samples.map(parseFirstTokenSample).filter((value) => value > 0);
return {
count: values.length,
p50Ms: percentile(values, 50),
p95Ms: percentile(values, 95)
};
};
const scoreWorker = async (client, target) => {
const workerId = workerIdForTarget(target);
const values = await client.mGet([
key("worker", workerId, "active_streams"),
key("worker", workerId, "active_sessions"),
key("worker", workerId, "ewma_first_token_ms"),
key("worker", workerId, "error_rate"),
key("worker", workerId, "memory_pressure"),
key("worker", workerId, "cpu_load"),
key("worker", workerId, "fd_pressure"),
key("worker", workerId, "drain")
]);
if (/^(1|true|yes)$/i.test(String(values[7] ?? ""))) return Number.POSITIVE_INFINITY;
return readNumber(values[0]) * 3 + readNumber(values[1]) + readNumber(values[2]) * 0.01 + readNumber(values[3]) * 5 + readNumber(values[4]) * 2 + readNumber(values[5]) * 2 + readNumber(values[6]) * 2;
};
const workerScoreFromValues = (values = []) => readNumber(values[0]) * 3 + readNumber(values[1]) + readNumber(values[2]) * 0.01 + readNumber(values[3]) * 5 + readNumber(values[4]) * 2 + readNumber(values[12]) * 2 + readNumber(values[13]) * 2;
return {
async pickTarget(fallbackPick, orderedTargets = targets) {
const client = await getClient();
if (!client) return fallbackPick();
let winner = null;
for (const target of orderedTargets) {
if (!await targetHealthy(target)) continue;
const score = await scoreWorker(client, target).catch(() => Number.POSITIVE_INFINITY);
if (!Number.isFinite(score)) continue;
if (!winner || score < winner.score) winner = { target, score };
}
return winner?.target ?? fallbackPick();
},
async registerSession(sessionId, target) {
const client = await getClient();
if (!client || !sessionId || !target) return;
const workerId = workerIdForTarget(target);
await client.multi().set(key("session", sessionId, "worker"), workerId, { EX: 60 * 60 * 24 * 30 }).set(key("session", sessionId, "target"), target, { EX: 60 * 60 * 24 * 30 }).exec().catch(() => null);
},
async resolveSessionTarget(sessionId) {
const client = await getClient();
if (!client || !sessionId) return null;
const target = await client.get(key("session", sessionId, "target")).catch(() => null);
return target && targets.includes(target) ? target : null;
},
async streamStarted(sessionId, target) {
const client = await getClient();
if (!client || !target) return;
const workerId = workerIdForTarget(target);
const streamKey = sessionId ? key("stream", sessionId, "status") : null;
const now = String(Date.now());
const multi = client.multi().incr(key("worker", workerId, "active_streams")).incr(key("worker", workerId, "stream_open_count")).set(key("worker", workerId, "last_stream_started_at"), now).set(key("worker", workerId, "heartbeat"), now, { EX: 30 });
if (streamKey) multi.set(streamKey, "active", { EX: 60 * 60 });
await multi.exec().catch(() => null);
},
async streamEnded(sessionId, target, { status = "closed" } = {}) {
const client = await getClient();
if (!client || !target) return;
const workerId = workerIdForTarget(target);
const streamKey = sessionId ? key("stream", sessionId, "status") : null;
const activeKey = key("worker", workerId, "active_streams");
const now = String(Date.now());
const nextValue = await client.decr(activeKey).catch(() => null);
const multi = client.multi().set(key("worker", workerId, "last_stream_ended_at"), now).set(key("worker", workerId, "heartbeat"), now, { EX: 30 });
if (Number(nextValue ?? 0) < 0) multi.set(activeKey, "0");
if (status === "aborted") multi.incr(key("worker", workerId, "stream_abort_count"));
if (status === "error") multi.incr(key("worker", workerId, "stream_error_count"));
if (streamKey) multi.set(streamKey, status, { EX: 600 });
await multi.exec().catch(() => null);
},
async firstTokenObserved(target, latencyMs) {
const client = await getClient();
if (!client || !target) return;
const workerId = workerIdForTarget(target);
const sample = Math.max(0, Math.round(Number(latencyMs) || 0));
const ewmaKey = key("worker", workerId, "ewma_first_token_ms");
const samplesKey = key("worker", workerId, "first_token_samples");
const prev = readNumber(await client.get(ewmaKey).catch(() => null));
const next = prev > 0 ? Math.round(prev * 0.8 + sample * 0.2) : sample;
const nowMs2 = Date.now();
const now = String(nowMs2);
await client.multi().set(ewmaKey, String(next)).set(key("worker", workerId, "last_first_token_ms"), String(sample)).set(key("worker", workerId, "last_first_token_at"), now).incr(key("worker", workerId, "first_token_count")).set(key("worker", workerId, "heartbeat"), now, { EX: 30 }).exec().catch(() => null);
await client.sendCommand([
"ZADD",
samplesKey,
String(nowMs2),
`${now}:${sample}:${Math.random().toString(36).slice(2)}`
]).catch(() => null);
await client.sendCommand(["ZREMRANGEBYSCORE", samplesKey, "0", String(nowMs2 - 60 * 60 * 1e3)]).catch(() => null);
await client.sendCommand(["EXPIRE", samplesKey, String(2 * 60 * 60)]).catch(() => null);
},
async getStatus() {
const client = await getClient();
const workers = [];
for (const target of targets) {
const workerId = workerIdForTarget(target);
const values = client ? await client.mGet([
key("worker", workerId, "active_streams"),
key("worker", workerId, "active_sessions"),
key("worker", workerId, "ewma_first_token_ms"),
key("worker", workerId, "error_rate"),
key("worker", workerId, "memory_pressure"),
key("worker", workerId, "heartbeat"),
key("worker", workerId, "drain"),
key("worker", workerId, "stream_open_count"),
key("worker", workerId, "stream_abort_count"),
key("worker", workerId, "stream_error_count"),
key("worker", workerId, "last_stream_started_at"),
key("worker", workerId, "last_stream_ended_at"),
key("worker", workerId, "cpu_load"),
key("worker", workerId, "fd_pressure"),
key("worker", workerId, "fd_count"),
key("worker", workerId, "container_pids"),
key("worker", workerId, "container_health"),
key("worker", workerId, "metrics_sampled_at"),
key("worker", workerId, "last_first_token_ms"),
key("worker", workerId, "last_first_token_at"),
key("worker", workerId, "first_token_count"),
key("worker", workerId, "heartbeat_source"),
key("worker", workerId, "heartbeat_ok"),
key("worker", workerId, "heartbeat_status_code"),
key("worker", workerId, "heartbeat_latency_ms"),
key("worker", workerId, "heartbeat_error")
]).catch(() => []) : [];
const firstToken5m = await firstTokenWindowStats(client, workerId, 5 * 60 * 1e3);
const firstToken1h = await firstTokenWindowStats(client, workerId, 60 * 60 * 1e3);
workers.push({
id: workerId,
target,
activeStreams: readNumber(values?.[0]),
activeSessions: readNumber(values?.[1]),
ewmaFirstTokenMs: readNumber(values?.[2]),
errorRate: readNumber(values?.[3]),
memoryPressure: readNumber(values?.[4]),
heartbeat: values?.[5] ? Number(values[5]) : null,
drain: /^(1|true|yes)$/i.test(String(values?.[6] ?? "")),
streamOpenCount: readNumber(values?.[7]),
streamAbortCount: readNumber(values?.[8]),
streamErrorCount: readNumber(values?.[9]),
lastStreamStartedAt: values?.[10] ? Number(values[10]) : null,
lastStreamEndedAt: values?.[11] ? Number(values[11]) : null,
cpuLoad: readNumber(values?.[12]),
fdPressure: readNumber(values?.[13]),
fdCount: readNumber(values?.[14]),
containerPids: readNumber(values?.[15]),
containerHealth: values?.[16] ?? null,
metricsSampledAt: values?.[17] ? Number(values[17]) : null,
lastFirstTokenMs: readNumber(values?.[18]),
lastFirstTokenAt: values?.[19] ? Number(values[19]) : null,
firstTokenCount: readNumber(values?.[20]),
heartbeatSource: values?.[21] ?? null,
heartbeatOk: values?.[22] == null ? null : /^(1|true|yes)$/i.test(String(values[22])),
heartbeatStatusCode: readNumber(values?.[23]),
heartbeatLatencyMs: readNumber(values?.[24]),
heartbeatError: values?.[25] || null,
firstToken5m,
firstToken1h,
score: workerScoreFromValues(values)
});
}
return {
enabled: Boolean(client),
namespace,
workers
};
}
};
}
async function readJsonBody(req) {
if (req.method === "GET" || req.method === "HEAD") return null;
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
if (chunks.length === 0) return null;
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw.trim()) return null;
return JSON.parse(raw);
}
function sendProxyResponse(res, upstream) {
res.status(upstream.status);
upstream.headers.forEach((value, key) => {
if (key === "transfer-encoding") return;
res.setHeader(key, value);
});
if (!upstream.body) {
res.end();
return;
}
Readable.fromWeb(upstream.body).pipe(res);
}
function extractSessionId(req, body) {
const fromParams = req.params?.sessionId ?? req.params?.id;
if (fromParams) return fromParams;
if (body?.session_id) return body.session_id;
if (body?.sessionId) return body.sessionId;
return null;
}
function firstUserText(message) {
return message?.content?.find?.((item) => item?.type === "text" && typeof item.text === "string")?.text ?? "";
}
var IMAGE_URL_LINE_RE = /^\[图片\d+]:\s*(\S.+)$/;
var PUBLIC_HTML_LINK_PATTERN = /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi;
var PUBLIC_HTML_MARKDOWN_LINK_PATTERN = /\[([^\]\n]*)\]\((https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html))\)/gi;
var USER_IDENTITY_BLOCK_PATTERN = /^\[用户身份\][\s\S]*?(?:\n{2,}|$)/;
var IMAGE_URL_LINES_PATTERN = /\n*\[图片\d+]: [^\n]+/g;
var TKMIND_VISION_NOTE_PATTERN = /\n*【TKMind 图片分析结果[\s\S]*$/;
function decodePathSegment(segment) {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
function encodeUrlPath(relativePath) {
return String(relativePath ?? "").split("/").filter(Boolean).map((part) => encodeURIComponent(part)).join("/");
}
function normalizeStaticHtmlRelativePath(relativePath) {
const parts = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== "..");
if (parts.length === 0) return "";
if (parts[0].toLowerCase() === "public") return ["public", ...parts.slice(1)].join("/");
if (parts.length === 1 && parts[0].toLowerCase().endsWith(".html")) return `public/${parts[0]}`;
return parts.join("/");
}
function canonicalizeStaticPageUrl(publicUrl, originalRelativePath, canonicalRelativePath) {
const originalClean = String(originalRelativePath ?? "").replace(/^\/+/, "");
if (!canonicalRelativePath || canonicalRelativePath === originalClean) return publicUrl;
const suffix = encodeUrlPath(originalClean).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return String(publicUrl).replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath));
}
function buildMissingPublicHtmlNotice(filename) {
return `\uFF08\u9875\u9762\u751F\u6210\u672A\u5B8C\u6210\uFF0C\u5DF2\u963B\u6B62\u663E\u793A\u5931\u6548\u94FE\u63A5\uFF1A${filename || "\u9875\u9762"}\u3002\uFF09`;
}
function publicHtmlExistsForUser(owner, relativePath, currentUser) {
const normalizedOwner = String(owner ?? "").trim().toLowerCase();
const normalizedUserId = String(currentUser?.id ?? "").trim().toLowerCase();
const normalizedUsername = String(currentUser?.username ?? "").trim().toLowerCase();
if (!normalizedOwner) return false;
if (normalizedOwner !== normalizedUserId && normalizedOwner !== normalizedUsername) return true;
const normalizedRelativePath = normalizeStaticHtmlRelativePath(relativePath);
if (!normalizedRelativePath || !normalizedRelativePath.toLowerCase().endsWith(".html")) return false;
const root2 = path8.resolve(process.cwd(), PUBLISH_ROOT_DIR, normalizedOwner);
const target = path8.resolve(root2, normalizedRelativePath);
if (target !== root2 && !target.startsWith(`${root2}${path8.sep}`)) return false;
return fs5.existsSync(target) && fs5.statSync(target).isFile();
}
function sanitizeOwnPublicHtmlUrl(publicUrl, owner, rawRelativePath, currentUser) {
const relativePath = decodePathSegment(rawRelativePath);
const canonicalRelativePath = normalizeStaticHtmlRelativePath(relativePath);
if (!publicHtmlExistsForUser(owner, canonicalRelativePath, currentUser)) {
return {
ok: false,
notice: buildMissingPublicHtmlNotice(path8.posix.basename(canonicalRelativePath || relativePath))
};
}
return {
ok: true,
url: canonicalizeStaticPageUrl(publicUrl, relativePath, canonicalRelativePath)
};
}
function sanitizePublicHtmlLinksInText(text, currentUser) {
let next = String(text ?? "").replace(
PUBLIC_HTML_MARKDOWN_LINK_PATTERN,
(match, label, url, owner, rawRelativePath) => {
const result = sanitizeOwnPublicHtmlUrl(url, owner, rawRelativePath, currentUser);
if (!result.ok) return result.notice;
return label ? `[${label}](${result.url})` : result.url;
}
);
return next.replace(PUBLIC_HTML_LINK_PATTERN, (match, owner, rawRelativePath) => {
const result = sanitizeOwnPublicHtmlUrl(match, owner, rawRelativePath, currentUser);
return result.ok ? result.url : result.notice;
});
}
function sanitizeUserVisibleMessageText(text, currentUser) {
return sanitizePublicHtmlLinksInText(
String(text ?? "").replace(USER_IDENTITY_BLOCK_PATTERN, "").replace(TKMIND_VISION_NOTE_PATTERN, "").replace(IMAGE_URL_LINES_PATTERN, "").trim(),
currentUser
);
}
function sanitizeSessionMessagePublicHtmlLinks(message, currentUser) {
if (!message || !Array.isArray(message.content)) return message;
let changed = false;
const imageUrls = extractImageUrlsFromMessage(message);
const content = message.content.map((item) => {
if (item?.type !== "text" || typeof item.text !== "string") return item;
const nextText = sanitizeUserVisibleMessageText(item.text, currentUser);
if (nextText === item.text) return item;
changed = true;
return { ...item, text: nextText };
});
const currentDisplayText = typeof message.metadata?.displayText === "string" ? message.metadata.displayText : null;
const nextDisplayText = currentDisplayText == null ? null : sanitizeUserVisibleMessageText(currentDisplayText, currentUser);
const metadataChanged = currentDisplayText != null && nextDisplayText !== currentDisplayText || !Array.isArray(message.metadata?.imageUrls) && imageUrls.length > 0;
if (!changed && !metadataChanged) return message;
return {
...message,
content,
metadata: {
...message.metadata ?? {},
...nextDisplayText != null ? { displayText: nextDisplayText } : {},
...!Array.isArray(message.metadata?.imageUrls) && imageUrls.length > 0 ? { imageUrls } : {}
}
};
}
function sanitizeSessionConversationPublicHtmlLinks(conversation, currentUser) {
if (!Array.isArray(conversation)) return conversation;
return conversation.map((message) => sanitizeSessionMessagePublicHtmlLinks(message, currentUser));
}
function createSessionEventSanitizer(currentUser, { onEvent } = {}) {
let buffer = "";
const flushChunk = (controller, chunk) => {
if (!chunk) return;
const block = String(chunk);
if (!block.includes("data: ")) {
controller.push(block);
return;
}
const lines = block.split("\n");
const sanitizedLines = lines.map((line) => {
if (!line.startsWith("data: ")) return line;
const raw = line.slice(6);
let event;
try {
event = JSON.parse(raw);
} catch {
return line;
}
if (typeof onEvent === "function") {
try {
onEvent(event);
} catch {
}
}
if (event?.type === "Message" && event.message) {
event.message = sanitizeSessionMessagePublicHtmlLinks(event.message, currentUser);
} else if (event?.type === "UpdateConversation" && Array.isArray(event.conversation)) {
event.conversation = sanitizeSessionConversationPublicHtmlLinks(event.conversation, currentUser);
}
return `data: ${JSON.stringify(event)}`;
});
controller.push(sanitizedLines.join("\n"));
};
return new Transform2({
transform(chunk, _encoding, callback) {
buffer += chunk.toString("utf8");
let boundary = buffer.indexOf("\n\n");
while (boundary >= 0) {
const block = buffer.slice(0, boundary + 2);
buffer = buffer.slice(boundary + 2);
flushChunk(this, block);
boundary = buffer.indexOf("\n\n");
}
callback();
},
flush(callback) {
flushChunk(this, buffer);
buffer = "";
callback();
}
});
}
function extractImageUrlsFromMessage(userMessage) {
const urls = [];
const imageUrls = userMessage?.metadata?.imageUrls;
if (Array.isArray(imageUrls)) {
urls.push(...imageUrls);
}
const content = userMessage?.content;
if (Array.isArray(content)) {
for (const item of content) {
if (item?.type === "image_url" && item.image_url?.url) {
urls.push(item.image_url.url);
continue;
}
if (item?.type !== "text" || typeof item.text !== "string") continue;
for (const line of item.text.split("\n")) {
const match = line.trim().match(IMAGE_URL_LINE_RE);
if (match?.[1]) urls.push(match[1].trim());
}
}
}
return [...new Set(urls.filter((url) => typeof url === "string" && url.trim()))];
}
function messageHasImages(userMessage) {
return extractImageUrlsFromMessage(userMessage).length > 0;
}
function prependAgentTextToUserMessage(body, transformText) {
const originalText = firstUserText(body?.user_message);
if (!originalText?.trim()) return body;
const agentText = transformText(originalText);
if (!agentText || agentText === originalText) return body;
const userMessage = body.user_message ?? {};
const content = Array.isArray(userMessage.content) ? [...userMessage.content] : [];
const firstTextIndex = content.findIndex(
(item) => item?.type === "text" && typeof item.text === "string"
);
if (firstTextIndex >= 0) {
content[firstTextIndex] = { ...content[firstTextIndex], text: agentText };
} else {
content.unshift({ type: "text", text: agentText });
}
return {
...body,
user_message: {
...userMessage,
content,
metadata: {
...userMessage.metadata ?? {},
displayText: userMessage.metadata?.displayText && String(userMessage.metadata.displayText).trim() ? userMessage.metadata.displayText : originalText
}
}
};
}
function injectCurrentTimeAnchor(body, timezone) {
const tz = String(timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai").trim() || "Asia/Shanghai";
return prependAgentTextToUserMessage(body, (originalText) => {
if (originalText.includes("TKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6")) return originalText;
return `${buildCurrentTimeAgentPrefix({ timezone: tz })}${originalText}`;
});
}
function injectTaskRoutingHint(body, sessionPolicy) {
return prependAgentTextToUserMessage(
body,
(originalText) => buildTaskRoutingAgentText(originalText, sessionPolicy)
);
}
async function buildVisionPayload({
userMessage,
userId,
publishLayout,
localFetchAsset,
llmProviderService: llmProviderService2,
imgproxySigner = null
}) {
void imgproxySigner;
if (!llmProviderService2 || !userId) return null;
const rawImageUrls = extractImageUrlsFromMessage(userMessage);
if (rawImageUrls.length === 0) return null;
const originalDisplayText = typeof userMessage?.metadata?.displayText === "string" && userMessage.metadata.displayText.trim() ? userMessage.metadata.displayText : Array.isArray(userMessage?.content) ? userMessage.content.filter((item) => item?.type === "text" && typeof item.text === "string").map((item) => item.text).join("\n").trim() : "";
const imageItems = [];
for (const rawUrl of rawImageUrls) {
try {
const match = rawUrl.match(/\/mindspace\/v1\/assets\/([^/?#]+)(?:\/download)?/);
let buffer;
let mimeType = "image/jpeg";
const fetchableUrl = (() => {
if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
if (rawUrl.startsWith("/")) {
const baseUrl = process.env.H5_PUBLIC_BASE_URL || "http://127.0.0.1:8081";
return new URL(rawUrl, baseUrl).toString();
}
return rawUrl;
})();
try {
const response = await undiciFetch2(fetchableUrl);
if (!response.ok) {
throw new Error(`Vision fetch failed: ${response.status}`);
}
buffer = Buffer.from(await response.arrayBuffer());
mimeType = response.headers.get("content-type") || "image/jpeg";
} catch (fetchErr) {
if (!match || !localFetchAsset) {
throw fetchErr;
}
const assetId = decodeURIComponent(match[1]);
console.warn(`Vision fetch fallback for asset ${assetId}:`, fetchErr instanceof Error ? fetchErr.message : fetchErr);
const asset = await localFetchAsset(userId, assetId);
buffer = asset.buffer;
mimeType = asset.mimeType;
}
let relativePath = rawUrl;
try {
const parsed = new URL(rawUrl);
relativePath = parsed.pathname + parsed.search;
} catch {
}
const publicStandardUrl = buildPublicStandardImageUrl(rawUrl, userId);
imageItems.push({
mimeType,
data: buffer.toString("base64"),
relativePath,
rawUrl,
embedUrl: publicStandardUrl ?? relativePath
});
} catch (err) {
console.warn("Vision image fetch skipped:", err instanceof Error ? err.message : err);
}
}
if (imageItems.length === 0) return null;
const visionDescription = await llmProviderService2.analyzeImagesWithVision(imageItems, "\u8BF7\u8BE6\u7EC6\u63CF\u8FF0\u56FE\u7247\u7684\u89C6\u89C9\u5185\u5BB9\uFF1A\u4E3B\u4F53\u3001\u989C\u8272\u3001\u98CE\u683C\u3001\u6784\u56FE\uFF0C\u4E0D\u8981\u751F\u6210\u4EE3\u7801\u6216\u9875\u9762\u65B9\u6848").catch(() => null);
const pathList = imageItems.map((item, i) => `\u56FE\u7247${i + 1}: <img src="${item.embedUrl}" alt="\u56FE\u7247${i + 1}">`).join(" ");
const publicUrlPrefix = publishLayout?.publicUrl ? `${String(publishLayout.publicUrl).replace(/\/$/, "")}/public/` : null;
const injectedNote = "\n\n\u3010TKMind \u56FE\u7247\u5206\u6790\u7ED3\u679C \u2014 \u4EC5\u4F9B\u6267\u884C\u53C2\u8003\uFF0C\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0\u6B64\u6BB5\u5185\u5BB9\u3011\n" + (visionDescription ? `Qwen VL \u56FE\u7247\u63CF\u8FF0\uFF1A
${visionDescription}
` : "") + `\u5199\u4F5C\u7EA6\u675F\uFF1A\u4E0D\u5F97\u6539\u5199\u56FE\u7247\u91CC\u4EBA\u7269\u7684\u5E74\u9F84\u3001\u6027\u522B\u3001\u4EBA\u6570\u6216\u4E3B\u4F53\u5173\u7CFB\uFF1B\u5982\u679C\u7528\u6237\u660E\u786E\u8981\u6C42\u513F\u7AE5\u8BED\u6C14\u6216\u7AE5\u8DA3\u98CE\u683C\uFF0C\u4E5F\u53EA\u80FD\u8C03\u6574\u8868\u8FBE\u65B9\u5F0F\uFF0C\u4E0D\u80FD\u628A\u56FE\u7247\u4E3B\u4F53\u6539\u5199\u6210\u513F\u7AE5\u573A\u666F\u3002
\u56FE\u7247 HTML \u5D4C\u5165\u8DEF\u5F84\uFF08\u76F4\u63A5\u5199\u5165 <img> \u6807\u7B7E\uFF1B\u4EE5\u4E0B\u90FD\u662F\u65E0\u9700 cookie \u7684\u516C\u5F00\u538B\u7F29\u6807\u51C6\u56FE\u7247\uFF0C\u7981\u6B62\u4F7F\u7528 local://\u3001/users/ \u79C1\u6709\u8DEF\u5F84\u3001\u539F\u56FE\u5730\u5740\u6216\u9700\u8981\u767B\u5F55\u6001\u7684\u4E0B\u8F7D\u94FE\u63A5\uFF09\uFF1A
${pathList}
\u6267\u884C\u8981\u6C42\uFF1A\u5FC5\u987B\u5148\u8C03\u7528 load_skill \u2192 static-page-publish\uFF08\u6BCF\u6B21\u751F\u6210\u9875\u9762\u90FD\u8981\u8C03\u7528\uFF0C\u4E0D\u53EF\u7701\u7565\uFF09\uFF0C\u518D\u7528 write_file \u5199\u5165 public/\u9875\u9762.html\uFF08\u7981\u6B62\u7528 shell/cat/heredoc \u5199 HTML\uFF0C\u5BB9\u5668\u5185\u6587\u4EF6\u4E0D\u4F1A\u51FA\u73B0\u5728\u516C\u7F51\u94FE\u63A5\uFF09\uFF1B` + (publicUrlPrefix ? `\u5B8C\u6210\u540E\u5411\u7528\u6237\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u94FE\u63A5\uFF0C\u683C\u5F0F\uFF1A[\u9875\u9762\u6807\u9898](${publicUrlPrefix}<\u6587\u4EF6\u540D>.html)\uFF1B` : "\u5B8C\u6210\u540E\u6309\u6280\u80FD\u8BF4\u660E\u91CC\u7684\u94FE\u63A5\u683C\u5F0F\u7ED9\u7528\u6237\u4E00\u4E2A Markdown \u53EF\u70B9\u51FB\u94FE\u63A5\uFF1B") + "\u4E0D\u8981\u5411\u7528\u6237\u5C55\u793A HTML \u4EE3\u7801\u5757\u3002";
let updatedContent = Array.isArray(userMessage?.content) ? [...userMessage.content] : [];
for (const item of imageItems) {
updatedContent = updatedContent.map((c) => {
if (c?.type !== "text" || typeof c.text !== "string") return c;
const updated = c.text.replaceAll(item.rawUrl, item.embedUrl);
return updated === c.text ? c : { ...c, text: updated };
});
}
const lastTextIdx = updatedContent.reduceRight(
(found, item, i) => found === -1 && item?.type === "text" ? i : found,
-1
);
if (lastTextIdx >= 0) {
updatedContent[lastTextIdx] = {
...updatedContent[lastTextIdx],
text: updatedContent[lastTextIdx].text + injectedNote
};
} else {
updatedContent.push({ type: "text", text: injectedNote });
}
return {
userMessage: {
...userMessage,
content: updatedContent,
metadata: {
...userMessage.metadata ?? {},
...originalDisplayText ? { displayText: originalDisplayText } : {}
}
},
billableImageCount: visionDescription ? 1 : 0
};
}
function createTkmindProxy({
apiTarget,
apiTargets: apiTargets2,
apiSecret,
userAuth: userAuth2,
llmProviderService: llmProviderService2,
localFetchAsset,
subscriptionService,
sessionSnapshotService,
conversationMemoryService
}) {
const targets = apiTargets2?.length ? apiTargets2 : apiTarget ? [apiTarget] : [];
const primaryTarget = targets[0] ?? apiTarget ?? "";
let rrIdx = 0;
let imgproxySigner = null;
if (process.env.IMGPROXY_BASE_URL && process.env.IMGPROXY_SIGNING_KEY && process.env.IMGPROXY_SIGNING_SALT) {
try {
imgproxySigner = createImgproxySigner(
process.env.IMGPROXY_SIGNING_KEY,
process.env.IMGPROXY_SIGNING_SALT
);
console.log("[TKMindProxy] imgproxy signer initialized");
} catch (err) {
console.warn("[TKMindProxy] imgproxy signer init failed:", err instanceof Error ? err.message : err);
}
}
async function targetHealthy(target) {
try {
const upstream = await apiFetch(target, apiSecret, "/status", {
method: "GET",
signal: AbortSignal.timeout(1500)
});
return upstream.ok;
} catch {
return false;
}
}
const runtimeRouter = createRuntimeRouter({ targets, targetHealthy });
function isGeneratedSessionName(name) {
const normalized = typeof name === "string" ? name.trim() : "";
return /^20\d{6}(?:[_-]\d+)?$/.test(normalized);
}
function hasDefaultSessionName(session) {
if (session?.user_set_name) return false;
const name = typeof session?.name === "string" ? session.name.trim() : "";
return name === "" || name === "New Chat" || name === "\u65B0\u5BF9\u8BDD" || isGeneratedSessionName(name);
}
async function enrichSessionHistory(sessions) {
if (!sessionSnapshotService?.isEnabled?.()) return;
const needsSummary = sessions.filter(
(session) => hasDefaultSessionName(session) || Number(session?.message_count ?? 0) === 0
);
if (needsSummary.length === 0) return;
try {
const summaries = await sessionSnapshotService.getHistorySummaries(needsSummary.map((s) => s.id));
for (const session of needsSummary) {
const summary = summaries.get(session.id);
if (!summary) continue;
if (summary.title && hasDefaultSessionName(session)) {
session.name = summary.title;
}
if (Number(session?.message_count ?? 0) === 0 && Number(summary.messageCount ?? 0) > 0) {
session.message_count = Number(summary.messageCount);
}
}
} catch {
}
}
function projectSessionSummary(session) {
return {
id: session?.id,
name: typeof session?.name === "string" ? session.name : "",
message_count: Number(session?.message_count ?? 0),
created_at: session?.created_at ?? void 0,
updated_at: session?.updated_at ?? void 0,
user_set_name: Boolean(session?.user_set_name),
recipe: session?.recipe ?? null
};
}
function sortSessionsByRecent(left, right) {
const leftTime = Date.parse(left?.updated_at ?? left?.created_at ?? "") || 0;
const rightTime = Date.parse(right?.updated_at ?? right?.created_at ?? "") || 0;
return rightTime - leftTime;
}
function matchesSessionQuery(summary, rawQuery) {
const query = String(rawQuery ?? "").trim().toLowerCase();
if (!query) return true;
const haystacks = [
summary?.name,
summary?.recipe?.title,
summary?.id
].filter((value) => typeof value === "string" && value.trim()).map((value) => value.toLowerCase());
return haystacks.some((value) => value.includes(query));
}
function paginateSessionConversation(conversation, beforeValue, limitValue) {
const visibleConversation = Array.isArray(conversation) ? conversation.filter((message) => message?.metadata?.userVisible) : [];
const total = visibleConversation.length;
const before = Math.max(Number(beforeValue ?? 0) || 0, 0);
const requestedLimit = Number(limitValue ?? 0) || 0;
if (requestedLimit <= 0) {
return {
conversation: visibleConversation,
page: {
total,
before: 0,
limit: total,
returned: total,
has_more_before: false
}
};
}
const limit = Math.min(Math.max(requestedLimit, 1), 200);
const end = Math.max(total - before, 0);
const start = Math.max(end - limit, 0);
const paged = visibleConversation.slice(start, end);
return {
conversation: paged,
page: {
total,
before,
limit,
returned: paged.length,
has_more_before: start > 0
}
};
}
async function pickTarget() {
if (targets.length <= 1) return primaryTarget;
const fallbackPick = async () => {
for (let i = 0; i < targets.length; i += 1) {
const target = targets[rrIdx];
rrIdx = (rrIdx + 1) % targets.length;
if (await targetHealthy(target)) return target;
}
return primaryTarget;
};
if (runtimeRouter) {
const orderedTargets = targets.map((_, i) => targets[(rrIdx + i) % targets.length]);
rrIdx = (rrIdx + 1) % targets.length;
return runtimeRouter.pickTarget(fallbackPick, orderedTargets);
}
return fallbackPick();
}
async function rememberSessionTarget(sessionId, target) {
if (!runtimeRouter || !sessionId || !target) return;
await runtimeRouter.registerSession(sessionId, target).catch(() => null);
}
async function markStreamStarted(sessionId, target) {
if (!runtimeRouter || !sessionId || !target) return;
await runtimeRouter.streamStarted(sessionId, target).catch(() => null);
}
async function markStreamEnded(sessionId, target, options = {}) {
if (!runtimeRouter || !sessionId || !target) return;
await runtimeRouter.streamEnded(sessionId, target, options).catch(() => null);
}
function markFirstTokenObserved(target, latencyMs) {
if (!runtimeRouter || !target) return;
void runtimeRouter.firstTokenObserved(target, latencyMs).catch(() => null);
}
async function getRuntimeStatus() {
const targetStatuses = [];
for (const target of targets) {
targetStatuses.push({
target,
healthy: await targetHealthy(target)
});
}
const routerStatus = runtimeRouter ? await runtimeRouter.getStatus().catch((err) => ({
enabled: false,
error: err instanceof Error ? err.message : String(err)
})) : { enabled: false, reason: "MEMIND_RUNTIME_REDIS_URL not configured" };
return {
publicBaseUrl: process.env.H5_PUBLIC_BASE_URL ?? null,
router: routerStatus,
toolRuntime: {
defaultMode: "chat",
codeToolMode: "code",
chatInjectsCodeTools: false,
codeRunsEnabled: ["1", "true", "yes", "on"].includes(
String(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED ?? "").trim().toLowerCase()
),
aiderTimeoutMs: Number(process.env.MEMIND_AIDER_TIMEOUT_MS ?? 6e5),
openhandsTimeoutMs: Number(process.env.MEMIND_OPENHANDS_TIMEOUT_MS ?? 9e5)
},
targets: targetStatuses
};
}
async function startSessionForUser(userId, {
workingDir,
sessionPolicy = null,
recipe = null
} = {}) {
const resolvedWorkingDir = workingDir ?? await userAuth2.resolveWorkingDir(userId);
const resolvedSessionPolicy = sessionPolicy ?? await userAuth2.getAgentSessionPolicy(userId);
const startTarget = await pickTarget();
const upstream = await apiFetch(startTarget, apiSecret, "/agent/start", {
method: "POST",
body: JSON.stringify({
...resolvedWorkingDir ? { working_dir: resolvedWorkingDir } : {},
enable_context_memory: resolvedSessionPolicy?.enableContextMemory,
...resolvedSessionPolicy?.extensionOverrides ? { extension_overrides: resolvedSessionPolicy.extensionOverrides } : {},
...recipe ? { recipe } : {}
})
});
const text = await upstream.text();
if (!upstream.ok) {
throw new Error(text || `\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25 (${upstream.status})`);
}
const session = JSON.parse(text);
if (!session?.id) {
throw new Error("\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25\uFF1A\u7F3A\u5C11 session id");
}
await rememberSessionTarget(session.id, startTarget);
await userAuth2.registerAgentSession(userId, session.id, startTarget);
if (resolvedSessionPolicy?.gooseMode) {
const modeRes = await apiFetch(startTarget, apiSecret, "/agent/update_session", {
method: "POST",
body: JSON.stringify({
session_id: session.id,
goose_mode: resolvedSessionPolicy.gooseMode
})
});
if (!modeRes.ok) {
const modeText = await modeRes.text().catch(() => "");
throw new Error(modeText || `\u8BBE\u7F6E\u4F1A\u8BDD\u6A21\u5F0F\u5931\u8D25 (${modeRes.status})`);
}
}
const publishLayout = await userAuth2.getUserPublishLayout(userId);
const userMemories = conversationMemoryService?.listMemories ? await conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => []) : [];
await reconcileAgentSession(
(pathname, init) => apiFetch(startTarget, apiSecret, pathname, init),
session.id,
{
workingDir: resolvedWorkingDir,
sessionPolicy: resolvedSessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
userMemories,
userContext: publishLayout ? {
userId,
displayName: publishLayout.displayName,
username: publishLayout.username,
slug: publishLayout.slug
} : null
}
);
await applySessionLlmProvider(session.id);
return session;
}
async function resolveTarget(sessionId) {
if (targets.length <= 1 || !sessionId) return primaryTarget;
try {
const routedTarget = await runtimeRouter?.resolveSessionTarget(sessionId);
if (routedTarget) return routedTarget;
const { target, node } = await userAuth2.getSessionTarget(sessionId);
if (target && targets.includes(target)) return target;
return targets[node] ?? primaryTarget;
} catch {
return primaryTarget;
}
}
async function applySessionLlmProvider(sessionId) {
if (!llmProviderService2 || !sessionId) return null;
try {
const target = await resolveTarget(sessionId);
return await llmProviderService2.applyBestProviderForSession(
sessionId,
(url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init)
);
} catch (err) {
console.warn(
"LLM provider apply skipped:",
err instanceof Error ? err.message : err
);
return null;
}
}
async function applyLocalFallbackForSession(sessionId) {
if (!llmProviderService2 || !sessionId) return null;
const target = await resolveTarget(sessionId);
return llmProviderService2.applyLocalFallbackForSession(
sessionId,
(url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init)
);
}
const visionActiveSessions = /* @__PURE__ */ new Set();
async function applyVisionProviderForSession(sessionId) {
if (!llmProviderService2 || !sessionId) return null;
const target = await resolveTarget(sessionId);
return llmProviderService2.applyVisionProviderForSession(
sessionId,
(url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init)
);
}
async function buildVisionBody(userMessage, userId, publishLayout) {
return buildVisionPayload({
userMessage,
userId,
publishLayout,
localFetchAsset,
llmProviderService: llmProviderService2,
imgproxySigner
});
}
async function getSessionPolicyForToolMode(userId, toolMode = "chat") {
if (toolMode === "code" && userAuth2.getCodeAgentSessionPolicy) {
return userAuth2.getCodeAgentSessionPolicy(userId);
}
return userAuth2.getAgentSessionPolicy(userId);
}
async function reconcileSessionPolicyForUser(userId, sessionId, { toolMode = "chat" } = {}) {
if (!userId || !sessionId) return;
const target = await resolveTarget(sessionId);
const workingDir = await userAuth2.resolveWorkingDir(userId);
const sessionPolicy = await getSessionPolicyForToolMode(userId, toolMode);
const publishLayout = await userAuth2.getUserPublishLayout(userId);
const userMemories = conversationMemoryService?.listMemories ? await conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => []) : [];
await reconcileAgentSession(
(pathname, init) => apiFetch(target, apiSecret, pathname, init),
sessionId,
{
workingDir,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
tolerateInvalidWorkingDir: true,
userMemories,
userContext: publishLayout ? {
userId,
displayName: publishLayout.displayName,
username: publishLayout.username,
slug: publishLayout.slug
} : null
}
);
}
async function submitSessionReplyForUser(userId, sessionId, requestId, userMessage, { toolMode = "chat" } = {}) {
if (!userId || !sessionId) throw new Error("\u7F3A\u5C11\u4F1A\u8BDD\u4FE1\u606F");
const owns = await userAuth2.ownsSession(userId, sessionId);
if (!owns) throw new Error("\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD");
const gate = await userAuth2.canUseChat(userId);
if (!gate.ok) {
const err = new Error(gate.message ?? "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u540E\u7EE7\u7EED\u4F7F\u7528");
err.code = gate.code;
err.status = 402;
throw err;
}
await reconcileSessionPolicyForUser(userId, sessionId, { toolMode });
await applySessionLlmProvider(sessionId);
const user = await userAuth2.getUserById(userId);
if (!user) throw new Error("\u7528\u6237\u4E0D\u5B58\u5728");
let finalUserMessage = userMessage;
if (llmProviderService2 && messageHasImages(userMessage) && await llmProviderService2.hasVisionKey()) {
const publishLayout = await userAuth2.getUserPublishLayout(userId).catch(() => null);
const visionResult = await buildVisionBody(userMessage, userId, publishLayout).catch(() => null);
if (visionResult?.userMessage) {
finalUserMessage = visionResult.userMessage;
}
if (visionResult?.billableImageCount > 0 && subscriptionService) {
await subscriptionService.consumeImageQuota(userId, visionResult.billableImageCount).catch((err) => {
console.warn(
"Subscription image quota consume skipped:",
err instanceof Error ? err.message : err
);
});
}
}
const policyState = await userAuth2.resolveUserPolicies(user);
let body = {
request_id: requestId,
user_message: finalUserMessage
};
if (!policyState.unrestricted) {
body = injectTaskRoutingHint(
body,
await userAuth2.getAgentSessionPolicy(userId)
);
}
const target = await resolveTarget(sessionId);
const upstream = await apiFetch(
target,
apiSecret,
`/sessions/${encodeURIComponent(sessionId)}/reply`,
{
method: "POST",
body: JSON.stringify(body)
}
);
if (!upstream.ok) {
const text = await upstream.text().catch(() => "");
throw new Error(text || `\u53D1\u9001\u5931\u8D25 (${upstream.status})`);
}
return { ok: true };
}
const requireUser = async (req, res, next) => {
try {
const session = req.userSession;
if (!session) {
res.status(401).json({ message: "\u672A\u767B\u5F55" });
return;
}
const me = await userAuth2.getMe(req.userToken);
if (!me) {
res.status(401).json({ message: "\u767B\u5F55\u5DF2\u8FC7\u671F" });
return;
}
req.currentUser = me;
next();
} catch (err) {
res.status(500).json({ message: err instanceof Error ? err.message : "\u8BA4\u8BC1\u5931\u8D25" });
}
};
const ensureChatAllowed = async (req, res, next) => {
const gate = await userAuth2.canUseChat(req.currentUser.id);
if (!gate.ok) {
res.status(402).json({
message: gate.message,
code: gate.code,
balanceCents: gate.balanceCents,
minRechargeCents: gate.minRechargeCents,
suggestedTiers: gate.suggestedTiers
});
return;
}
next();
};
const handlers = {
"POST /agent/start": [
requireUser,
ensureChatAllowed,
async (req, res) => {
try {
const workingDir = await userAuth2.resolveWorkingDir(req.currentUser.id);
const sessionPolicy = await userAuth2.getAgentSessionPolicy(req.currentUser.id);
const startTarget = await pickTarget();
const upstream = await apiFetch(startTarget, apiSecret, "/agent/start", {
method: "POST",
body: JSON.stringify({
working_dir: workingDir,
enable_context_memory: sessionPolicy.enableContextMemory,
...sessionPolicy.extensionOverrides ? { extension_overrides: sessionPolicy.extensionOverrides } : {},
...req.body?.recipe ? { recipe: req.body.recipe } : {}
})
});
const text = await upstream.text();
if (!upstream.ok) {
res.status(upstream.status).send(text);
return;
}
const session = JSON.parse(text);
if (session?.id) {
await rememberSessionTarget(session.id, startTarget);
await userAuth2.registerAgentSession(
req.currentUser.id,
session.id,
startTarget
);
if (sessionPolicy.gooseMode) {
const modeRes = await apiFetch(startTarget, apiSecret, "/agent/update_session", {
method: "POST",
body: JSON.stringify({
session_id: session.id,
goose_mode: sessionPolicy.gooseMode
})
});
if (!modeRes.ok) {
const modeText = await modeRes.text().catch(() => "");
res.status(modeRes.status).send(modeText || "\u8BBE\u7F6E\u4F1A\u8BDD\u6A21\u5F0F\u5931\u8D25");
return;
}
}
const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser.id);
const userMemories = conversationMemoryService?.listMemories ? await conversationMemoryService.listMemories(req.currentUser.id, { limit: 40 }).catch(() => []) : [];
const api = (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init);
try {
await reconcileAgentSession(api, session.id, {
workingDir,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
userMemories,
userContext: publishLayout ? {
userId: req.currentUser.id,
displayName: publishLayout.displayName,
username: publishLayout.username,
slug: publishLayout.slug
} : null
});
} catch (reconcileErr) {
res.status(500).json({
message: reconcileErr instanceof Error ? `\u4F1A\u8BDD\u7B56\u7565\u540C\u6B65\u5931\u8D25\uFF1A${reconcileErr.message}` : "\u4F1A\u8BDD\u7B56\u7565\u540C\u6B65\u5931\u8D25"
});
return;
}
await applySessionLlmProvider(session.id);
}
res.status(upstream.status).json(session);
} catch (err) {
res.status(500).json({ message: err instanceof Error ? err.message : "\u542F\u52A8\u4F1A\u8BDD\u5931\u8D25" });
}
}
],
"POST /agent/resume": [
requireUser,
ensureChatAllowed,
async (req, res) => {
try {
const sessionId = req.body?.session_id;
if (!sessionId) {
res.status(400).json({ message: "\u7F3A\u5C11 session_id" });
return;
}
const owns = await userAuth2.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" });
return;
}
const resumeTarget = await resolveTarget(sessionId);
const upstream = await apiFetch(resumeTarget, apiSecret, "/agent/resume", {
method: "POST",
body: JSON.stringify(req.body ?? {})
});
const text = await upstream.text();
if (!upstream.ok) {
res.status(upstream.status).send(text);
return;
}
const payload = JSON.parse(text);
const skipReconcile = req.body?.skip_reconcile === true;
if (!skipReconcile) {
const workingDir = await userAuth2.resolveWorkingDir(req.currentUser.id);
const sessionPolicy = await userAuth2.getAgentSessionPolicy(req.currentUser.id);
const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser.id);
const userMemories = conversationMemoryService?.listMemories ? await conversationMemoryService.listMemories(req.currentUser.id, { limit: 40 }).catch(() => []) : [];
try {
await reconcileAgentSession(
(pathname, init) => apiFetch(resumeTarget, apiSecret, pathname, init),
sessionId,
{
workingDir,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
tolerateInvalidWorkingDir: true,
userMemories,
userContext: publishLayout ? {
userId: req.currentUser.id,
displayName: publishLayout.displayName,
username: publishLayout.username,
slug: publishLayout.slug
} : null
}
);
} catch (reconcileErr) {
res.status(500).json({
message: reconcileErr instanceof Error ? `\u4F1A\u8BDD\u6062\u590D\u540E\u7B56\u7565\u540C\u6B65\u5931\u8D25\uFF1A${reconcileErr.message}` : "\u4F1A\u8BDD\u6062\u590D\u540E\u7B56\u7565\u540C\u6B65\u5931\u8D25"
});
return;
}
}
await applySessionLlmProvider(sessionId);
res.status(upstream.status).json(payload);
} catch (err) {
res.status(500).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : "\u6062\u590D\u4F1A\u8BDD\u5931\u8D25",
"\u6062\u590D\u4F1A\u8BDD\u5931\u8D25"
)
});
}
}
],
"GET /sessions": [
requireUser,
async (req, res) => {
try {
const offset = Math.max(Number(req.query?.offset ?? 0) || 0, 0);
const requestedLimit = Number(req.query?.limit ?? 20) || 20;
const limit = Math.min(Math.max(requestedLimit, 1), 100);
const query = String(req.query?.query ?? "").trim();
const owned = await userAuth2.listOwnedSessionIds(req.currentUser.id);
const sessionsById = /* @__PURE__ */ new Map();
let healthyTargets = 0;
let lastFailure = null;
for (const target of targets) {
try {
const upstream = await apiFetch(target, apiSecret, "/sessions", {
method: "GET",
signal: AbortSignal.timeout(12e3)
});
const text = await upstream.text();
if (!upstream.ok) {
lastFailure = text || `upstream ${upstream.status}`;
continue;
}
healthyTargets += 1;
const payload = JSON.parse(text);
for (const item of payload.sessions ?? []) {
if (owned.has(item.id)) sessionsById.set(item.id, item);
}
} catch (err) {
lastFailure = sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : "\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25",
"\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25"
);
}
}
if (healthyTargets === 0) {
res.status(502).json({ message: lastFailure ?? "\u540E\u7AEF\u8FDE\u63A5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5" });
return;
}
if (healthyTargets < targets.length) {
res.setHeader("X-TKMind-Degraded", "1");
}
const sessions = [...sessionsById.values()].sort(sortSessionsByRecent);
await enrichSessionHistory(sessions);
if (typeof userAuth2.getSessionOrigins === "function" && sessions.length > 0) {
try {
const origins = await userAuth2.getSessionOrigins(sessions.map((s) => s.id));
for (const session of sessions) {
session.origin = origins.get(session.id) ?? "h5";
}
} catch {
}
}
const summaries = sessions.map(projectSessionSummary).filter((item) => matchesSessionQuery(item, query));
const paged = summaries.slice(offset, offset + limit);
res.json({
sessions: paged,
page: {
total: summaries.length,
offset,
limit,
has_more: offset + paged.length < summaries.length,
query
}
});
} catch (err) {
res.status(500).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : "\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25",
"\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25"
)
});
}
}
]
};
const sessionScoped = (build) => [
requireUser,
async (req, res, next) => {
try {
const body = req.body ?? await readJsonBody(req);
req.body = body;
const sessionId = extractSessionId(req, body);
if (!sessionId) {
res.status(400).json({ message: "\u7F3A\u5C11 session_id" });
return;
}
const owns = await userAuth2.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" });
return;
}
req.agentSessionId = sessionId;
await build(req, res, next);
} catch (err) {
res.status(500).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : "\u8BF7\u6C42\u5931\u8D25",
"\u8BF7\u6C42\u5931\u8D25"
)
});
}
}
];
const proxySessionEvents = async (req, res, sessionId, { onAfterFinish, onEvent } = {}) => {
const upstreamAbort = new AbortController();
const streamRequestedAt = Date.now();
let clientClosed = false;
const abortUpstream = () => {
clientClosed = true;
upstreamAbort.abort();
};
req.once("close", abortUpstream);
try {
const pathname = `/sessions/${sessionId}/events`;
const sessionTarget = await resolveTarget(sessionId);
const upstream = await apiFetch(sessionTarget, apiSecret, pathname, {
method: "GET",
signal: upstreamAbort.signal,
headers: {
Accept: "text/event-stream",
"Last-Event-ID": req.get("last-event-id") ?? ""
}
});
if (clientClosed) return;
if (!upstream.ok || !upstream.body) {
const text = await upstream.text().catch(() => "");
res.status(upstream.status).send(text);
return;
}
res.status(upstream.status);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
let pendingBalance = null;
const billingTransform = createSseBillingTransform({
onFinish: async (event) => {
const billingRequestId = event.request_id ?? event.chat_request_id ?? null;
const result = await userAuth2.billSessionUsage(
req.currentUser.id,
sessionId,
event.token_state,
billingRequestId
);
if (result.ok && result.costCents > 0 && result.balanceCents != null) {
pendingBalance = {
balanceCents: result.balanceCents,
tokensUsed: result.tokensUsed ?? void 0,
lastUsage: {
inputTokens: result.deltaInputTokens ?? 0,
outputTokens: result.deltaOutputTokens ?? 0,
costCents: result.costCents
}
};
}
if (typeof onAfterFinish === "function") {
void Promise.resolve().then(() => onAfterFinish(sessionId, req.currentUser.id)).catch(() => {
});
}
}
});
const source = Readable.fromWeb(upstream.body);
let firstChunkSeen = false;
const firstTokenProbe = new Transform2({
transform(chunk, _encoding, callback) {
if (!firstChunkSeen && chunk?.length > 0) {
firstChunkSeen = true;
markFirstTokenObserved(sessionTarget, Date.now() - streamRequestedAt);
}
callback(null, chunk);
}
});
const linkSanitizer = createSessionEventSanitizer(req.currentUser, { onEvent });
const waitForDrain = () => new Promise((resolve) => res.once("drain", resolve));
const writeClientChunk = async (chunk) => {
if (res.writableEnded || clientClosed) return;
let needsDrain = !res.write(chunk);
if (pendingBalance != null && !res.writableEnded && !clientClosed) {
needsDrain = !res.write(appendBalanceEvent(pendingBalance)) || needsDrain;
pendingBalance = null;
}
if (needsDrain && !res.writableEnded && !clientClosed) {
await waitForDrain();
}
};
const clientSink = new Writable({
write(chunk, _encoding, callback) {
writeClientChunk(chunk).then(() => callback(), callback);
}
});
const keepalive = setInterval(() => {
if (!res.writableEnded && !clientClosed) {
const ok = res.write(": keepalive\n\n");
if (!ok) source.pause();
}
}, 2e4);
res.on("drain", () => source.resume());
await markStreamStarted(sessionId, sessionTarget);
let streamCloseStatus = "closed";
try {
try {
await pipeline(source, firstTokenProbe, linkSanitizer, billingTransform, clientSink);
} catch (err) {
streamCloseStatus = clientClosed || upstreamAbort.signal.aborted ? "aborted" : "error";
throw err;
}
} finally {
clearInterval(keepalive);
req.off("close", abortUpstream);
await markStreamEnded(sessionId, sessionTarget, { status: streamCloseStatus });
if (!res.writableEnded) res.end();
}
} catch (err) {
req.off("close", abortUpstream);
if (clientClosed || upstreamAbort.signal.aborted) {
if (!res.writableEnded) res.end();
return;
}
res.status(502).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : "SSE \u4EE3\u7406\u5931\u8D25",
"SSE \u4EE3\u7406\u5931\u8D25"
)
});
}
};
const proxyFallback = async (req, res) => {
try {
const pathname = req.originalUrl.replace(/^\/api/, "") || "/";
if (isNativeH5ApiPath(pathname)) {
res.status(404).json({
message: "H5 \u672C\u5730\u63A5\u53E3\u672A\u627E\u5230\uFF0C\u8BF7\u786E\u8BA4\u670D\u52A1\u7AEF\u5DF2\u66F4\u65B0\u5E76\u91CD\u542F",
code: "not_found"
});
return;
}
const isAgentRunPath = req.method === "POST" && pathname === "/agent/runs" || req.method === "GET" && /^\/agent\/runs\/[^/]+$/.test(pathname) || req.method === "GET" && /^\/agent\/runs\/[^/]+\/events$/.test(pathname);
if (isAgentRunPath) {
res.status(503).json({
message: "Agent Run \u63A5\u53E3\u9700\u5728 Portal \u672C\u5730\u5904\u7406\uFF0C\u8BF7\u786E\u8BA4 server.mjs \u5DF2\u66F4\u65B0\u5E76\u91CD\u542F\u540E\u7AEF",
code: "AGENT_RUNS_NATIVE_REQUIRED"
});
return;
}
const policyState = await userAuth2.resolveUserPolicies(req.currentUser);
const capabilityState = await userAuth2.resolveUserCapabilities(req.currentUser);
const gate = evaluateProxyRequest(req.method, pathname, policyState.policies, {
unrestricted: policyState.unrestricted
});
if (!gate.allowed) {
res.status(403).json({ message: gate.reason ?? "\u8BE5 API \u5DF2\u88AB\u7B56\u7565\u7981\u6B62" });
return;
}
if (/^\/agent\/harness_(bootstrap|remember)$/.test(pathname) && !capabilityState.unrestricted && !capabilityState.capabilities.context_memory) {
res.status(403).json({ message: "\u5F53\u524D\u8D26\u6237\u672A\u5F00\u901A\u9879\u76EE\u8BB0\u5FC6\uFF0C\u65E0\u6CD5\u8BBF\u95EE\u8BE5 API" });
return;
}
const isReplyPath = pathname.match(/^\/sessions\/[^/]+\/reply$/) && req.method === "POST";
const sessionMatch = pathname.match(/^\/sessions\/([^/]+)/);
if (isReplyPath) {
res.status(410).json({
message: "\u804A\u5929\u63D0\u4EA4\u5165\u53E3\u5DF2\u7EDF\u4E00\u4E3A POST /agent/runs",
code: "AGENT_RUNS_REQUIRED"
});
return;
}
let baseBody = req.body;
if (isReplyPath && !policyState.unrestricted) {
const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser.id).catch(() => null);
baseBody = injectCurrentTimeAnchor(req.body, publishLayout?.timezone);
baseBody = injectTaskRoutingHint(
baseBody,
await userAuth2.getAgentSessionPolicy(req.currentUser.id)
);
}
if (isReplyPath && llmProviderService2) {
const sessionId = sessionMatch?.[1];
if (sessionId) {
const hasImages = messageHasImages(req.body?.user_message);
if (hasImages && await llmProviderService2.hasVisionKey()) {
const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser?.id).catch(() => null);
const visionResult = await buildVisionBody(
baseBody?.user_message ?? req.body?.user_message,
req.currentUser?.id,
publishLayout
).catch(() => null);
if (visionResult?.userMessage) {
baseBody = { ...baseBody ?? req.body, user_message: visionResult.userMessage };
}
if (visionResult?.billableImageCount > 0 && subscriptionService) {
await subscriptionService.consumeImageQuota(
req.currentUser.id,
visionResult.billableImageCount
).catch((err) => {
console.warn(
"Subscription image quota consume skipped:",
err instanceof Error ? err.message : err
);
});
}
}
}
}
const body = req.method === "GET" || req.method === "HEAD" ? void 0 : baseBody ? JSON.stringify(baseBody) : void 0;
const fallbackTarget = req.goosedTarget ?? (sessionMatch ? await resolveTarget(sessionMatch[1]) : primaryTarget);
const upstream = await apiFetch(fallbackTarget, apiSecret, pathname, {
method: req.method,
body,
headers: {
Accept: req.get("accept") ?? "*/*",
"Last-Event-ID": req.get("last-event-id") ?? ""
}
});
if (req.method === "GET" && /^\/sessions\/[^/]+$/.test(pathname) && upstream.ok) {
const payload = await upstream.json().catch(() => null);
if (payload && Array.isArray(payload.conversation)) {
const sanitizedConversation = sanitizeSessionConversationPublicHtmlLinks(
payload.conversation,
req.currentUser
);
const { conversation, page } = paginateSessionConversation(
sanitizedConversation,
req.query?.history_before,
req.query?.history_limit
);
payload.conversation = conversation;
payload.conversation_page = page;
}
res.status(upstream.status).json(payload ?? {});
return;
}
sendProxyResponse(res, upstream);
} catch (err) {
res.status(502).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : "\u4EE3\u7406\u5931\u8D25",
"\u4EE3\u7406\u5931\u8D25"
)
});
}
};
return {
requireUser,
ensureChatAllowed,
applySessionLlmProvider,
applyLocalFallbackForSession,
applyVisionProviderForSession,
reconcileSessionPolicyForUser,
handlers,
sessionScoped,
proxyFallback,
proxySessionEvents,
resolveTarget,
startSessionForUser,
getRuntimeStatus,
submitSessionReplyForUser,
apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init),
apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init)
};
}
// tool-gateway.mjs
import { spawn as nodeSpawn } from "node:child_process";
import { EventEmitter } from "node:events";
var CODE_EXECUTORS = /* @__PURE__ */ new Set(["aider", "openhands"]);
var DEFAULT_STDIO_LIMIT = 64 * 1024;
function envFlag2(value, fallback = false) {
const raw = String(value ?? "").trim().toLowerCase();
if (!raw) return fallback;
return ["1", "true", "yes", "on"].includes(raw);
}
function positiveInteger2(value, fallback) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.floor(n);
}
function normalizeExecutor2(value, fallback = "aider") {
const normalized = String(value ?? fallback).trim().toLowerCase();
if (CODE_EXECUTORS.has(normalized)) return normalized;
return fallback;
}
function csvSet(value) {
return new Set(
String(value ?? "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean)
);
}
function appendLimited(current, chunk, limit) {
const next = `${current}${Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk ?? "")}`;
if (next.length <= limit) return next;
return next.slice(next.length - limit);
}
function extractToolInstruction(userMessage) {
const content = userMessage?.content;
if (typeof content === "string") return content.trim();
if (!Array.isArray(content)) {
return String(userMessage?.text ?? userMessage?.value ?? "").trim();
}
return content.map((item) => {
if (typeof item === "string") return item;
if (item?.type === "text") return item.text ?? "";
return "";
}).map((item) => String(item ?? "").trim()).filter(Boolean).join("\n").trim();
}
function createToolGateway({
llmProviderService: llmProviderService2,
env = process.env,
spawnImpl = nodeSpawn
} = {}) {
const enabled = envFlag2(env.MEMIND_TOOL_GATEWAY_ENABLED, false);
const dryRun = envFlag2(env.MEMIND_TOOL_GATEWAY_DRY_RUN, false);
const defaultExecutor = normalizeExecutor2(env.MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR, "aider");
const openhandsTaskTypes = csvSet(
env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES ?? "repo_refactor,multi_file,complex_repo"
);
const stdioLimit = positiveInteger2(env.MEMIND_TOOL_GATEWAY_STDIO_LIMIT, DEFAULT_STDIO_LIMIT);
function getStatus() {
return {
enabled,
dryRun,
protocol: "agent-run-v1",
executors: ["aider", "openhands"],
defaultExecutor,
openhandsTaskTypes: [...openhandsTaskTypes]
};
}
function selectExecutor({ userMessage, taskType } = {}) {
const metadata = userMessage?.metadata ?? {};
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
const requested = normalizeExecutor2(runMetadata.executor, "");
if (requested) return requested;
const normalizedTaskType = String(taskType ?? runMetadata.taskType ?? "").trim().toLowerCase();
if (openhandsTaskTypes.has(normalizedTaskType)) return "openhands";
return defaultExecutor;
}
async function executeJob({
runId,
requestId,
userId,
userMessage,
taskType,
cwd,
timeoutMs = 15 * 60 * 1e3
} = {}) {
if (!enabled) {
throw new Error("Tool Gateway is disabled");
}
if (!llmProviderService2?.getExecutorLaunchPlan) {
throw new Error("Tool Gateway missing llm provider service");
}
const instruction = extractToolInstruction(userMessage);
if (!instruction) {
throw new Error("Tool Gateway job missing instruction");
}
const executor = selectExecutor({ userMessage, taskType });
const plan = await llmProviderService2.getExecutorLaunchPlan(executor, {
cwd,
mode: "headless",
instruction,
purpose: "default",
includeSecret: true
});
if (!plan?.ok) {
throw new Error(plan?.message ?? `Tool Gateway launch plan unavailable for ${executor}`);
}
if (dryRun) {
return {
ok: true,
dryRun: true,
executor,
protocol: "agent-run-v1",
cwd: plan.cwd,
command: plan.command,
args: plan.args ?? [],
runId,
requestId,
userId
};
}
return await new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
const child = spawnImpl(plan.command, plan.args ?? [], {
cwd: plan.cwd,
env: { ...process.env, ...plan.env ?? {} },
stdio: ["ignore", "pipe", "pipe"]
});
const cleanup = () => {
if (timer) clearTimeout(timer);
};
const timer = setTimeout(() => {
child.kill("SIGTERM");
const err = new Error(`Tool Gateway job timed out after ${timeoutMs}ms`);
err.code = "TOOL_GATEWAY_TIMEOUT";
reject(err);
}, timeoutMs);
if (child.stdout instanceof EventEmitter) {
child.stdout.on("data", (chunk) => {
stdout = appendLimited(stdout, chunk, stdioLimit);
});
}
if (child.stderr instanceof EventEmitter) {
child.stderr.on("data", (chunk) => {
stderr = appendLimited(stderr, chunk, stdioLimit);
});
}
child.on("error", (err) => {
cleanup();
reject(err);
});
child.on("exit", (code, signal) => {
cleanup();
if (code === 0) {
resolve({
ok: true,
executor,
protocol: "agent-run-v1",
cwd: plan.cwd,
command: plan.command,
args: plan.args ?? [],
exitCode: code,
signal: signal ?? null,
stdout,
stderr
});
return;
}
const err = new Error(`Tool Gateway executor ${executor} exited with ${signal ?? code}`);
err.code = "TOOL_GATEWAY_EXECUTOR_FAILED";
err.executor = executor;
err.exitCode = code;
err.signal = signal ?? null;
err.stdout = stdout;
err.stderr = stderr;
reject(err);
});
});
}
return {
getStatus,
selectExecutor,
executeJob
};
}
// user-auth.mjs
import crypto5 from "node:crypto";
import fs8 from "node:fs";
import path11 from "node:path";
import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from "@node-rs/argon2";
// billing.mjs
function loadBillingConfig() {
const useBackendCost = process.env.H5_USE_BACKEND_COST === "1";
const marginMultiplier = Number(process.env.H5_MARGIN_MULTIPLIER ?? 1);
return {
useBackendCost,
usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2),
marginMultiplier: Number.isFinite(marginMultiplier) && marginMultiplier > 0 ? marginMultiplier : 1,
inputCentsPer1k: Number(process.env.H5_BILL_INPUT_CENTS_PER_1K ?? 2),
outputCentsPer1k: Number(process.env.H5_BILL_OUTPUT_CENTS_PER_1K ?? 6),
minBillCents: Number(process.env.H5_MIN_BILL_CENTS ?? 1)
};
}
function normalizeTokenState(raw) {
if (!raw || typeof raw !== "object") {
return {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
accumulatedInputTokens: 0,
accumulatedOutputTokens: 0,
accumulatedTotalTokens: 0,
accumulatedCost: null
};
}
return {
inputTokens: Number(raw.inputTokens ?? raw.input_tokens ?? 0),
outputTokens: Number(raw.outputTokens ?? raw.output_tokens ?? 0),
totalTokens: Number(raw.totalTokens ?? raw.total_tokens ?? 0),
accumulatedInputTokens: Number(
raw.accumulatedInputTokens ?? raw.accumulated_input_tokens ?? raw.inputTokens ?? 0
),
accumulatedOutputTokens: Number(
raw.accumulatedOutputTokens ?? raw.accumulated_output_tokens ?? raw.outputTokens ?? 0
),
accumulatedTotalTokens: Number(
raw.accumulatedTotalTokens ?? raw.accumulated_total_tokens ?? raw.totalTokens ?? 0
),
accumulatedCost: raw.accumulatedCost ?? raw.accumulated_cost ?? null
};
}
function computeDeltaCostCents(previous, current, config = loadBillingConfig()) {
const prevCost = previous?.lastAccumulatedCost == null ? null : Number(previous.lastAccumulatedCost);
const currCost = current.accumulatedCost == null ? null : Number(current.accumulatedCost);
const margin = config.marginMultiplier ?? 1;
if (config.useBackendCost && prevCost != null && currCost != null && currCost >= prevCost) {
const deltaUsd = currCost - prevCost;
if (deltaUsd <= 0) return 0;
return Math.max(config.minBillCents, Math.ceil(deltaUsd * config.usdCnyRate * 100 * margin));
}
if (config.useBackendCost && prevCost == null && currCost != null && currCost > 0) {
return Math.max(config.minBillCents, Math.ceil(currCost * config.usdCnyRate * 100 * margin));
}
const prevIn = Number(previous?.lastInputTokens ?? 0);
const prevOut = Number(previous?.lastOutputTokens ?? 0);
const deltaIn = Math.max(0, current.accumulatedInputTokens - prevIn);
const deltaOut = Math.max(0, current.accumulatedOutputTokens - prevOut);
if (deltaIn === 0 && deltaOut === 0) return 0;
const raw = deltaIn / 1e3 * config.inputCentsPer1k + deltaOut / 1e3 * config.outputCentsPer1k;
if (raw <= 0) return 0;
return Math.max(config.minBillCents, Math.ceil(raw));
}
// billing-recharge.mjs
function loadRechargeConfig() {
const tiers = (process.env.H5_RECHARGE_TIERS_CENTS ?? "500,1000,3000,5000,10000,20000").split(",").map((value) => Number(value.trim())).filter((value) => Number.isFinite(value) && value > 0);
return {
tiersCents: tiers.length ? tiers : [500, 1e3, 3e3, 5e3, 1e4, 2e4],
minRechargeCents: Number(process.env.H5_MIN_RECHARGE_CENTS ?? 500),
orderTtlMs: Number(process.env.H5_RECHARGE_ORDER_TTL_MS ?? 15 * 60 * 1e3),
maxPendingOrders: Number(process.env.H5_RECHARGE_MAX_PENDING ?? 3),
dailyLimitCents: Number(process.env.H5_RECHARGE_DAILY_LIMIT_CENTS ?? 2e5)
};
}
function buildInsufficientBalancePayload(balanceCents, config = loadRechargeConfig()) {
return {
code: "INSUFFICIENT_BALANCE",
message: "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u540E\u7EE7\u7EED\u4F7F\u7528",
balanceCents: Number(balanceCents ?? 0),
minRechargeCents: config.minRechargeCents,
suggestedTiers: config.tiersCents
};
}
// user-space.mjs
import fs6 from "node:fs";
import path9 from "node:path";
var UPLOAD_ZONE_CODES = ["oa", "public"];
function resolveMindspaceStorageRoot(h5Root, env = process.env) {
return path9.resolve(env.MINDSPACE_STORAGE_ROOT ?? path9.join(h5Root, "data", "mindspace"));
}
var WORKSPACE_HINTS_FILENAME2 = ".tkmindhints";
var LEGACY_WORKSPACE_HINTS_FILENAME2 = ".goosehints";
function renderUserSpaceBrandingBlock(userAddressName) {
const name = userAddressName || "\u7528\u6237";
return `## \u54C1\u724C\u4E0E\u79F0\u547C\uFF08\u786C\u6027\uFF09
- \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4ECB\u7ECD\u4EA7\u54C1\u65F6\u7528 TKMind\uFF0C\u4E0D\u8981\u79F0 goose\u3001Goose\u3001goosed
- \u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\uFF0C\u7528 **${name}** \u79F0\u547C\u7528\u6237\uFF08\u53EF\u8F85\u4EE5\u300C\u4F60/\u60A8\u300D\uFF09\uFF0C**\u7981\u6B62**\u628A\u7528\u6237\u53EB\u4F5C TKMind
- \u4EC5\u5728\u5F00\u573A\u6216\u7528\u6237\u6253\u62DB\u547C\u65F6\u4F7F\u7528\u65F6\u6BB5\u95EE\u5019\uFF0C\u4E14\u987B\u4E0E\u300CTKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6\u300D\u4E00\u81F4\uFF08\u5982\u300C${name}\uFF0C\u65E9\u4E0A\u597D\u300D\uFF09\uFF1B\u666E\u901A\u56DE\u590D\u76F4\u63A5\u4F5C\u7B54\uFF0C\u4E0D\u8981\u6BCF\u6761\u90FD\u52A0\u95EE\u5019\uFF1B\u7981\u6B62\u628A\u7528\u6237\u53EB\u4F5C TKMind
- \u4E0D\u8981\u63CF\u8FF0\u672C\u5DE5\u4F5C\u533A\u4E3A\u300CRust goose \u9879\u76EE\u300D\u6216\u300Cgoose AI \u6846\u67B6\u300D
- \u672C\u5DE5\u4F5C\u533A\u662F TKMind **MindSpace \u7528\u6237\u7A7A\u95F4**\uFF0C\u7528\u4E8E OA/\u516C\u5F00\u6587\u4EF6\u7BA1\u7406\u4E0E\u9759\u6001\u9875\u9762\u751F\u6210
`;
}
function resolveZoneDir(workspaceRoot, categoryCode) {
return path9.join(workspaceRoot, categoryCode);
}
function resolveZoneFilePath(workspaceRoot, categoryCode, filename) {
return path9.join(resolveZoneDir(workspaceRoot, categoryCode), filename);
}
function zoneLabel(categoryCode) {
return SYSTEM_CATEGORIES.find((item) => item.code === categoryCode)?.name ?? categoryCode;
}
function ensureUserZoneDirs(workspaceRoot) {
for (const code of UPLOAD_ZONE_CODES) {
fs6.mkdirSync(resolveZoneDir(workspaceRoot, code), { recursive: true });
}
}
function mirrorAssetToZone({ workspaceRoot, categoryCode, filename, sourcePath }) {
if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return null;
if (!sourcePath || !fs6.existsSync(sourcePath)) return null;
ensureUserZoneDirs(workspaceRoot);
const dest = resolveZoneFilePath(workspaceRoot, categoryCode, filename);
fs6.mkdirSync(path9.dirname(dest), { recursive: true });
fs6.copyFileSync(sourcePath, dest);
return dest;
}
function isPathInsideUserWorkspace(workspaceRoot, requestedPath) {
const base = path9.resolve(workspaceRoot);
const resolved = path9.resolve(requestedPath);
return resolved === base || resolved.startsWith(`${base}${path9.sep}`);
}
function renderUserSpaceHints({ username, workspaceRoot, displayName, slug }) {
const addressName = resolveUserAddressName({ displayName, username, slug });
const zoneLines = UPLOAD_ZONE_CODES.map(
(code) => `- \`${code}/\` \u2014 ${zoneLabel(code)}\uFF08\u7528\u6237\u4E0A\u4F20\u843D\u5728\u6B64\u5206\u533A\uFF09`
);
return `# TKMind \u7528\u6237\u7A7A\u95F4\u5206\u533A
\u4F60\u662F\u7528\u6237 **${addressName}** \u7684\u4E13\u5C5E TKMind \u52A9\u624B\u3002\u5F53\u524D\u4F1A\u8BDD\u5DE5\u4F5C\u533A\uFF08\u552F\u4E00\u6587\u4EF6\u6839\u76EE\u5F55\uFF09\uFF1A
\`${workspaceRoot}\`
${renderUserSpaceBrandingBlock(addressName)}
## \u5206\u533A\u89C4\u5219\uFF08\u786C\u6027\uFF09
\u7528\u6237\u4E0A\u4F20\u7684\u6587\u4EF6**\u53EA\u4F1A**\u51FA\u73B0\u5728\u4E0B\u5217\u5B50\u76EE\u5F55\u4E4B\u4E00\uFF1A
${zoneLines.join("\n")}
## \u67E5\u627E / \u8BFB\u53D6\u6587\u4EF6
1. \u5728\u5DE5\u4F5C\u533A\u5185\u641C\u7D22\uFF0C\u4F8B\u5982 \`tree oa/\`\u3001\`find oa -name '*.csv'\`\u3001\`cat oa/\u6587\u4EF6\u540D.csv\`
2. \u7528\u6237\u8BF4\u300COA \u533A\u300D\u2192 \`oa/\`\uFF1B\u300C\u79C1\u4EBA\u533A\u300D\u2192 \`private/\`\uFF1B\u300C\u516C\u5F00\u533A\u300D\u2192 \`public/\`
3. **\u7981\u6B62**\u8BBF\u95EE\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\`${PUBLISH_ROOT_DIR}/\` \u6839\u76EE\u5F55\uFF08\u975E\u672C\u7528\u6237\uFF09\u3001\`data/mindspace/\`\u3001\u4E3B\u673A\u7EDD\u5BF9\u8DEF\u5F84
4. **\u7981\u6B62**\u7528\u516C\u7F51 URL \u5217\u76EE\u5F55\u6216\u8BFB CSV\uFF1B\u516C\u7F51\u94FE\u63A5\u4EC5\u7528\u4E8E\u5206\u4EAB\u5DF2\u53D1\u5E03 HTML
5. \u627E\u4E0D\u5230\u65F6\u8BF4\u660E\u8BE5\u5206\u533A\u5185\u6CA1\u6709\u8BE5\u6587\u4EF6\uFF0C\u8BF7\u7528\u6237\u786E\u8BA4\u4E0A\u4F20\u5230\u4E86\u54EA\u4E2A\u5206\u533A
## \u751F\u6210\u9875\u9762
- \u7528\u6237\u8981\u7F51\u9875 / HTML / \u62A5\u544A / \u5206\u4EAB\u94FE\u63A5\u65F6\uFF1A**\u4F60\u5FC5\u987B\u4EB2\u81EA\u5B8C\u6210**\uFF0C\u4E0D\u8981\u63A8\u7ED9\u7528\u6237\u624B\u52A8\u64CD\u4F5C
- \u5148 \`load_skill\` \u2192 \`static-page-publish\`\uFF0C\u518D\u4F7F\u7528 \`write_file\` \u521B\u5EFA \`public/\u9875\u9762\u540D.html\`\uFF08\u9700\u8981\u8C03\u6574\u5DF2\u6709\u9875\u9762\u65F6\u7528 \`edit_file\`\uFF09
- \u9ED8\u8BA4\u53EA\u751F\u6210 HTML\uFF1B\u4E0D\u8981\u5728\u6CA1\u6709\u660E\u786E\u9700\u6C42\u65F6\u5F3A\u5236\u751F\u6210 Word\u3001PDF\u3001\u957F\u56FE\u7B49\u4F34\u751F\u6587\u4EF6
- \u53EA\u6709\u7528\u6237\u660E\u786E\u8981\u6C42 Word / docx \u4E0B\u8F7D\u65F6\uFF1A\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`docx-generate\`\uFF0C\u751F\u6210 \`public/\u6587\u4EF6\u540D.docx\` \u5E76\u786E\u8BA4\u6587\u4EF6\u5B58\u5728\uFF0C\u518D\u5199 HTML \u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863
- \u7528\u6237\u660E\u786E\u8981\u6C42 Word / docx \u4E0B\u8F7D\u65F6\uFF0C\u5FC5\u987B\u7528 sandbox-fs \u7684 \`generate_docx\` \u5199\u5165 \`public/*.docx\`\uFF0C\u4E0D\u8981\u7528 shell/computercontroller \u751F\u6210\u751F\u4EA7\u4E0B\u8F7D\u6587\u4EF6
- \u82E5\u7528 \`apps__create_app\` \u8BBE\u8BA1/\u9884\u89C8\u9875\u9762\uFF1A\u8FD9\u4E00\u6B65\u53EA\u662F\u5728 Apps \u7A97\u53E3\u5185\u521B\u5EFA\u4EA4\u4E92\u5F0F App\uFF0C**\u8FD8\u6CA1\u6709\u516C\u7F51\u94FE\u63A5**\uFF1B\u5FC5\u987B\u7D27\u63A5\u7740\u6309 \`static-page-publish\` skill \u628A\u6700\u7EC8\u5185\u5BB9 \`write_file\` \u843D\u5230 \`public/*.html\`\uFF0C\u624D\u80FD\u7ED9\u51FA\u771F\u5B9E\u53EF\u8BBF\u95EE\u7684\u94FE\u63A5
- \u5199\u5165 \`<head>\` \u7684 **mindspace-cover** \u5143\u6570\u636E\uFF08\u8BE6\u89C1 \`.agents/skills/static-page-publish/SKILL.md\`\uFF09
- \u4FDD\u5B58\u540E\u7ACB\u5373\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 \`[\u6807\u9898](URL)\`\uFF1B\u5199\u5165 \`public/\u9875\u9762.html\` \u65F6 URL \u4E3A \`.../MindSpace/<\u7528\u6237ID>/public/\u9875\u9762.html\`
- \u7ED9\u94FE\u63A5\u65F6\u6309 \`static-page-publish\` skill \u91CC\u7684\u516C\u7F51\u524D\u7F00\u6A21\u677F\u62FC\u63A5\u771F\u5B9E\u5730\u5740\uFF0C\u4E0D\u786E\u5B9A\u57DF\u540D\u65F6\u7528\u76F8\u5BF9\u8DEF\u5F84 \`public/xxx.html\` \u8BF4\u660E\uFF0C\u4E0D\u8981\u731C\u4E00\u4E2A\u57DF\u540D\u5145\u6570
- **\u7981\u6B62**\u56DE\u590D\u300C\u8BF7\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E \`write_file\` / \`edit_file\` \u8C03\u7528\u5DF2\u5931\u8D25\u5E76\u8BF4\u660E\u5177\u4F53\u9519\u8BEF
## \u5DE5\u4F5C\u533A\u6587\u4EF6\u4E0E OA \u754C\u9762
- \u5199\u5165 \`oa/\`\u3001\`private/\`\u3001\`public/\` \u4E0B\u4EFB\u610F\u5C42\u7EA7\u7684\u652F\u6301\u7C7B\u578B\u6587\u4EF6\uFF08docx\u3001md\u3001txt\u3001csv\u3001pdf\u3001\u56FE\u7247\u7B49\uFF09\u4F1A**\u81EA\u52A8\u540C\u6B65**\u5230 MindSpace \u8D44\u4EA7\u5E93\uFF08\u542B\u5B50\u76EE\u5F55\uFF0C\u5982 \`oa/\u8BD7\u6B4C\u6563\u6587/\u590F\u65E5\u7684\u8BD7\u7BC7.md\`\uFF09
- \u6253\u5F00\u5BF9\u5E94\u5206\u533A\u6216\u4FDD\u5B58\u6587\u4EF6\u540E\u4F1A\u51FA\u73B0\u5728\u754C\u9762\u4E2D\uFF0C\u53EF\u76F4\u63A5\u9884\u89C8\u6216\u4E0B\u8F7D
- \u751F\u6210 docx/pdf \u7B49 OA \u8D44\u6599\u65F6\uFF0C\u53EF\u76F4\u63A5 \`write_file\` \u5230 \`oa/<\u5B50\u76EE\u5F55>/<\u6587\u4EF6\u540D>\`\uFF0C\u65E0\u9700\u624B\u52A8\u590D\u5236\u5230\u6839\u76EE\u5F55
- \u7528\u6237\u4E0A\u4F20\u7684\u6587\u4EF6\u4ECD\u4EE5\u754C\u9762\u5165\u5E93\u4E3A\u51C6\uFF0C\u5E76\u955C\u50CF\u5230\u4E0A\u8FF0\u5206\u533A
`;
}
function buildUserSpaceConstraints({ username, workspaceRoot, publicBaseUrl, slug, displayName }) {
const addressName = resolveUserAddressName({ displayName, username, slug });
const zoneList = UPLOAD_ZONE_CODES.map((code) => `\`${code}/\`\uFF08${zoneLabel(code)}\uFF09`).join("\u3001");
return [
"## TKMind \u7528\u6237\u7A7A\u95F4\u5206\u533A\uFF08\u786C\u6027\u7EA6\u675F\uFF09",
"",
`- \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\u7528 **${addressName}** \u79F0\u547C\u7528\u6237\uFF0C\u7981\u6B62\u628A\u7528\u6237\u53EB\u4F5C TKMind`,
"- \u7981\u6B62\u79F0 goose / Goose / goosed \u6216\u300CRust goose \u9879\u76EE\u300D",
`- \u7528\u6237 **${addressName}** \u7684 Agent \u5DE5\u4F5C\u533A\uFF1A\`${workspaceRoot}\``,
`- \u7528\u6237\u4E0A\u4F20\u843D\u5728\u5206\u533A\u5B50\u76EE\u5F55\uFF1A${zoneList}\uFF08\u4F8B\u5982 OA \u6587\u4EF6\u5728 \`oa/\`\uFF09`,
"- **\u67E5\u627E\u6587\u4EF6**\uFF1A\u53EA\u5728\u4E0A\u8FF0\u5DE5\u4F5C\u533A\u5185\u641C\u7D22\uFF08\u5982 `oa/2025-12-06T13-34_export.csv`\uFF09",
"- **\u7981\u6B62**\uFF1A\u8BBF\u95EE\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001MindSpace \u6839\u76EE\u5F55\u3001data/mindspace \u5185\u90E8\u8DEF\u5F84\u3001\u4E3B\u673A\u7EDD\u5BF9\u8DEF\u5F84",
"- **\u7981\u6B62**\u7528\u516C\u7F51 URL \u5217\u76EE\u5F55\u6216\u8BFB CSV\uFF1B\u751F\u6210 HTML \u5199\u5165 `public/` \u5E76\u7ED9\u51FA\u516C\u7F51\u94FE\u63A5",
"- **\u751F\u6210\u9875\u9762**\uFF1A\u5148 `load_skill` \u2192 `static-page-publish`\uFF0C\u518D\u7528 `write_file` / `edit_file` \u5199\u5165 `public/*.html`\uFF1B**\u7981\u6B62**\u8BA9\u7528\u6237\u624B\u52A8\u4FDD\u5B58\u6587\u4EF6",
"- **\u9ED8\u8BA4\u53EA\u751F\u6210 HTML**\uFF1A\u4E0D\u8981\u5728\u6CA1\u6709\u660E\u786E\u9700\u6C42\u65F6\u5F3A\u5236\u751F\u6210 Word\u3001PDF\u3001\u957F\u56FE\u7B49\u4F34\u751F\u6587\u4EF6",
"- **Word \u4E0B\u8F7D\u9875**\uFF1A\u82E5\u7528\u6237\u660E\u786E\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 `load_skill` \u2192 `docx-generate` \u751F\u6210 `public/*.docx` \u5E76\u786E\u8BA4\u6587\u4EF6\u5B58\u5728\uFF0C\u518D\u5728 HTML \u91CC\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863",
"- \u7528 `apps__create_app` \u8BBE\u8BA1\u9875\u9762\u65F6\uFF0C\u6700\u540E\u4E00\u6B65\u4ECD\u8981\u6309 `static-page-publish` skill \u628A\u5185\u5BB9 `write_file` \u843D\u5230 `public/*.html`\uFF0C\u518D\u7ED9\u51FA\u4E0B\u65B9\u524D\u7F00\u62FC\u51FA\u7684\u771F\u5B9E\u94FE\u63A5\uFF0C\u4E0D\u8981\u505C\u5728 App \u9636\u6BB5\u5C31\u56DE\u590D\u94FE\u63A5",
"- **\u9700\u8981\u67E5\u5B9E\u65F6/\u771F\u5B9E\u4E16\u754C\u4FE1\u606F\uFF08\u5982\u673A\u6784\u540D\u5355\u3001\u65B0\u95FB\u3001\u884C\u60C5\uFF09\u65F6**\uFF1A\u5148 `load_skill` \u2192 `web`\uFF0C\u4F18\u5148\u7528\u5176 `web_search`/`fetch_url`\uFF1B\u56FD\u5185\u73AF\u5883\u4E0B google.com \u4E0D\u53EF\u8FBE\uFF0C`web_search` \u591A\u6B21\u65E0\u679C\u65F6\u6539\u8D70 Bing/360 \u7B49\u53EF\u8FBE\u641C\u7D22\u6E90\uFF0C\u907F\u514D\u5BF9\u53CD\u722C\u7AD9\u70B9\u53CD\u590D\u786C\u6293",
"- **Word \u4E0B\u8F7D\u9875**\uFF1A\u82E5\u7528\u6237\u660E\u786E\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 `load_skill` \u2192 `docx-generate` \u751F\u6210 `public/*.docx` \u5E76\u786E\u8BA4\u6587\u4EF6\u5B58\u5728\uFF0C\u518D\u5728 HTML \u91CC\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863\uFF1B\u4E0D\u8981\u7528 shell/computercontroller \u751F\u6210\u751F\u4EA7\u4E0B\u8F7D\u6587\u4EF6",
publicBaseUrl && slug ? `- \u516C\u7F51 HTML \u524D\u7F00\uFF08\u516C\u5F00\u533A\uFF09\uFF1A\`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/\`\uFF08\u5199\u5165 \`public/\u9875\u9762.html\` \u65F6\u5206\u4EAB\u94FE\u63A5\u5FC5\u987B\u542B \`public/\`\uFF09` : null
].filter(Boolean).join("\n");
}
function ensureUserSpaceHints(workspaceRoot, context) {
const hintsPath = path9.join(workspaceRoot, WORKSPACE_HINTS_FILENAME2);
const legacyPath = path9.join(workspaceRoot, LEGACY_WORKSPACE_HINTS_FILENAME2);
const content = renderUserSpaceHints({ ...context, workspaceRoot });
fs6.writeFileSync(hintsPath, content, "utf8");
if (fs6.existsSync(legacyPath)) {
fs6.unlinkSync(legacyPath);
}
return hintsPath;
}
async function syncUserZonesFromAssets(pool2, storageRoot, userId, workspaceRoot) {
ensureUserZoneDirs(workspaceRoot);
const placeholders = UPLOAD_ZONE_CODES.map(() => "?").join(", ");
const [rows] = await pool2.query(
`SELECT a.original_filename, c.category_code, v.storage_key
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.user_id = ? AND a.status <> 'deleted' AND c.category_code IN (${placeholders})`,
[userId, ...UPLOAD_ZONE_CODES]
);
for (const row of rows) {
const sourcePath = path9.join(storageRoot, row.storage_key);
mirrorAssetToZone({
workspaceRoot,
categoryCode: row.category_code,
filename: row.original_filename,
sourcePath
});
}
return { workspaceRoot, mirrored: rows.length };
}
async function ensureUserSpaceLayout({
pool: pool2,
storageRoot,
userId,
username,
displayName,
publicBaseUrl,
slug,
workspaceRoot
}) {
ensureUserZoneDirs(workspaceRoot);
if (pool2 && userId) {
await syncUserZonesFromAssets(pool2, storageRoot, userId, workspaceRoot);
}
const context = {
username: username ?? slug,
displayName,
workspaceRoot,
slug,
publicBaseUrl
};
ensureUserSpaceHints(workspaceRoot, context);
return {
workspaceRoot,
zonesRoot: workspaceRoot,
zonesPublicDir: resolveZoneDir(workspaceRoot, "public"),
constraints: buildUserSpaceConstraints(context),
...context
};
}
// skills-registry.mjs
import fs7 from "node:fs";
import path10 from "node:path";
import { fileURLToPath as fileURLToPath4 } from "node:url";
var __dirname3 = path10.dirname(fileURLToPath4(import.meta.url));
var DEFAULT_USER_SKILLS = {
web: true,
search: true,
"schedule-assistant": true,
"form-builder": true,
"table-viewer": true,
"product-campaign-page": true,
"docx-generate": true,
"long-image-download": true,
[PUBLISH_SKILL_NAME]: false
};
var USER_ROLE_SKILL_PRESETS = {
user: DEFAULT_USER_SKILLS,
creator: {
...DEFAULT_USER_SKILLS,
[PUBLISH_SKILL_NAME]: true,
kanban: true,
timeline: true
},
developer: {
...DEFAULT_USER_SKILLS,
git: true,
"diff-viewer": true,
"code-playground": true,
"test-runner": true
}
};
function parseSkillFrontmatter(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return { name: null, description: "" };
const block = match[1];
const name = block.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? null;
const description = block.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? "";
return { name, description };
}
function listPlatformSkillCatalog(h5Root = __dirname3) {
const skillsRoot = path10.join(h5Root, "skills");
if (!fs7.existsSync(skillsRoot)) return [];
const catalog = [];
for (const entry of fs7.readdirSync(skillsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillPath = path10.join(skillsRoot, entry.name, "SKILL.md");
if (!fs7.existsSync(skillPath)) continue;
const raw = fs7.readFileSync(skillPath, "utf8");
const meta = parseSkillFrontmatter(raw);
const name = meta.name || entry.name;
catalog.push({
name,
dirName: entry.name,
label: name,
description: meta.description || "\u5E73\u53F0\u901A\u7528\u6280\u80FD",
category: "platform",
requiresPublish: name === PUBLISH_SKILL_NAME
});
}
return catalog.sort((a, b) => a.name.localeCompare(b.name));
}
function isValidSkillName(catalog, name) {
return catalog.some((item) => item.name === name);
}
function normalizeSkillPatch(catalog, patch) {
const normalized = {};
for (const [key, value] of Object.entries(patch ?? {})) {
if (!isValidSkillName(catalog, key)) continue;
normalized[key] = Boolean(value);
}
return normalized;
}
function resolveSkillMap(roleGrants, userOverrides, catalog) {
const resolved = {};
for (const item of catalog) {
const key = item.name;
if (key in userOverrides) {
resolved[key] = userOverrides[key];
} else if (key in roleGrants) {
resolved[key] = roleGrants[key];
} else {
resolved[key] = DEFAULT_USER_SKILLS[key] ?? false;
}
}
return resolved;
}
function grantedSkillNames(skillMap) {
return Object.entries(skillMap).filter(([, enabled]) => enabled).map(([name]) => name);
}
function applySkillGrantsToCapabilities(capabilities, skillMap) {
const effective = { ...capabilities };
const enabled = grantedSkillNames(skillMap);
if (enabled.length > 0) {
effective.skills = true;
}
if (enabled.includes(PUBLISH_SKILL_NAME)) {
effective.static_publish = true;
}
return effective;
}
function copySkillTree(srcDir, destDir) {
fs7.mkdirSync(destDir, { recursive: true });
for (const entry of fs7.readdirSync(srcDir, { withFileTypes: true })) {
const from = path10.join(srcDir, entry.name);
const to = path10.join(destDir, entry.name);
if (entry.isDirectory()) {
copySkillTree(from, to);
} else {
fs7.copyFileSync(from, to);
}
}
}
function syncSkillsToWorkspace({
h5Root,
publishDir,
skillMap,
catalog,
user,
publicBaseUrl
}) {
const enabled = new Set(grantedSkillNames(skillMap));
const platformNames = new Set(catalog.map((item) => item.name));
const agentsSkills = path10.join(publishDir, ".agents", "skills");
if (fs7.existsSync(agentsSkills)) {
for (const entry of fs7.readdirSync(agentsSkills, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillName = entry.name;
const catalogItem = catalog.find((item) => item.name === skillName || item.dirName === skillName);
const resolvedName = catalogItem?.name ?? skillName;
if (platformNames.has(resolvedName) && !enabled.has(resolvedName)) {
fs7.rmSync(path10.join(agentsSkills, entry.name), { recursive: true, force: true });
}
}
}
for (const item of catalog) {
if (!enabled.has(item.name)) continue;
const srcDir = path10.join(h5Root, "skills", item.dirName);
const destDir = path10.join(agentsSkills, item.name);
if (item.name === PUBLISH_SKILL_NAME && user) {
ensurePublishSkillInstalled(publishDir, {
slug: String(user.id).trim().toLowerCase(),
username: user.username ? String(user.username).trim().toLowerCase() : void 0,
publicBaseUrl,
publishDir
});
continue;
}
if (fs7.existsSync(srcDir)) {
if (fs7.existsSync(destDir)) fs7.rmSync(destDir, { recursive: true, force: true });
copySkillTree(srcDir, destDir);
}
}
}
// user-auth.mjs
var USER_COOKIE = "tkmind_user_session";
function safeEqual(left, right) {
const a = Buffer.from(left);
const b = Buffer.from(right);
return a.length === b.length && crypto5.timingSafeEqual(a, b);
}
var PASSWORD_ALGORITHM_PBKDF2 = "pbkdf2-sha512";
var PASSWORD_ALGORITHM_ARGON2ID = "argon2id";
var ARGON2_MEMORY = 64 * 1024;
var ARGON2_PASSES = 3;
var ARGON2_PARALLELISM = 1;
var ARGON2_TAG_LENGTH = 32;
function hashPasswordPbkdf2(password, salt) {
return crypto5.pbkdf2Sync(password, salt, 1e5, 64, "sha512").toString("hex");
}
function hashPasswordArgon2id(password, salt) {
return argon2HashRawSync(password, {
salt: Buffer.from(salt, "hex"),
parallelism: ARGON2_PARALLELISM,
outputLen: ARGON2_TAG_LENGTH,
memoryCost: ARGON2_MEMORY,
timeCost: ARGON2_PASSES,
algorithm: Argon2Algorithm.Argon2id
}).toString("hex");
}
function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) {
const salt = crypto5.randomBytes(16).toString("hex");
if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) {
return {
salt,
passwordHash: hashPasswordArgon2id(password, salt),
passwordAlgorithm: PASSWORD_ALGORITHM_ARGON2ID
};
}
return {
salt,
passwordHash: hashPasswordPbkdf2(password, salt),
passwordAlgorithm: PASSWORD_ALGORITHM_PBKDF2
};
}
function verifyPassword(password, row) {
const algorithm = row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2;
if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) {
return safeEqual(hashPasswordArgon2id(password, row.salt), row.password_hash);
}
return safeEqual(hashPasswordPbkdf2(password, row.salt), row.password_hash);
}
function normalizeUsername(username) {
return username.trim().toLowerCase();
}
function isValidUsername(username) {
return /^[a-z0-9_]{2,32}$/.test(username);
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function hashSessionToken(token) {
return crypto5.createHash("sha256").update(token).digest("hex");
}
function createUserAuth(pool2, options = {}) {
const usersRoot = path11.resolve(options.usersRoot ?? "/tmp/tkmind_go_users");
const h5Root = path11.resolve(options.h5Root ?? path11.join(usersRoot, ".."));
const env = options.env ?? process.env;
const storageRoot = resolveMindspaceStorageRoot(h5Root, env);
const publicBaseUrl = resolvePublicBaseUrl(env);
const skillCatalog = listPlatformSkillCatalog(h5Root);
const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500);
const lowBalanceGiftThresholdCents = Number(options.lowBalanceGiftThresholdCents ?? 100);
const lowBalanceGiftAmountCents = Number(options.lowBalanceGiftAmountCents ?? 1e3);
const sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1e3);
const loginMaxFailures = Number(options.loginMaxFailures ?? 5);
const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1e3);
const persistSessions = options.persistSessions !== false && Boolean(pool2);
let rechargeNotifier = typeof options.onRechargeNotification === "function" ? options.onRechargeNotification : null;
const subscriptionService = options.subscriptionService ?? null;
const sessions = /* @__PURE__ */ new Map();
const loginFailures = /* @__PURE__ */ new Map();
const pruneLoginFailures = (now = Date.now()) => {
for (const [key, state] of loginFailures) {
if (state.resetAt <= now) loginFailures.delete(key);
}
};
const pruneSessions = (now = Date.now()) => {
for (const [token, session] of sessions) {
if (session.expiresAt <= now) sessions.delete(token);
}
};
const storeSession = async (userId, role, token, now = Date.now()) => {
const expiresAt = now + sessionTtlMs;
sessions.set(token, { userId, role, expiresAt });
if (!persistSessions) return expiresAt;
await pool2.query(
`INSERT INTO h5_login_sessions (id, user_id, token_hash, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)`,
[crypto5.randomUUID(), userId, hashSessionToken(token), expiresAt, now]
);
return expiresAt;
};
const revokeAllSessionsForUser = async (userId, now = Date.now()) => {
for (const [token, session] of sessions) {
if (session.userId === userId) sessions.delete(token);
}
if (!persistSessions) return;
await pool2.query(
`UPDATE h5_login_sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`,
[now, userId]
);
};
const ensureWorkspace = (workspaceRoot) => {
fs8.mkdirSync(workspaceRoot, { recursive: true });
};
const isAdminRole = (user) => user?.role === "admin";
const getUserById = async (userId) => {
const [rows] = await pool2.query(
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
u.plan_type, u.workspace_root,
s.quota_bytes, s.used_bytes, s.reserved_bytes,
w.balance_cents, w.tokens_used,
(SELECT COALESCE(SUM(ABS(amount_cents)), 0)
FROM h5_billing_ledger l
WHERE l.user_id = u.id AND l.type = 'deduct') AS spent_cents
FROM h5_users u
LEFT JOIN h5_user_spaces s ON s.user_id = u.id
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
WHERE u.id = ?`,
[userId]
);
return rows[0] ?? null;
};
const publicUser = (row) => {
const balanceCents = Number(row.balance_cents ?? 0);
const spentCents = Number(row.spent_cents ?? 0);
const base = {
id: row.id,
username: row.username,
slug: row.slug ?? row.username,
email: row.email ?? null,
displayName: row.display_name,
role: row.role,
status: row.status,
planType: row.plan_type ?? "free",
workspaceRoot: row.workspace_root,
balanceCents,
totalCreditCents: balanceCents + spentCents,
tokensUsed: Number(row.tokens_used ?? 0),
spaceQuotaBytes: Number(row.quota_bytes ?? 0),
spaceUsedBytes: Number(row.used_bytes ?? 0),
spaceReservedBytes: Number(row.reserved_bytes ?? 0),
spaceAvailableBytes: Math.max(
0,
Number(row.quota_bytes ?? 0) - Number(row.used_bytes ?? 0) - Number(row.reserved_bytes ?? 0)
)
};
const publishKey = row.id;
return {
...base,
publishSlug: publishKey,
publishUrl: buildPublicUrl(publicBaseUrl, publishKey),
publishSkillName: PUBLISH_SKILL_NAME
};
};
const publishLayoutFor = async (user, { migrateLegacy = true } = {}) => {
const web = ensureUserPublishLayout({
h5Root,
publicBaseUrl,
user,
legacyUsersRoot: migrateLegacy ? usersRoot : null
});
const space = await ensureUserSpaceLayout({
pool: pool2,
storageRoot,
userId: user.id,
username: user.username ?? web.slug,
displayName: user.displayName,
publicBaseUrl,
slug: web.slug,
workspaceRoot: web.publishDir
});
const hintsContext = {
slug: web.slug,
username: user.username ?? web.username,
displayName: user.displayName,
publicBaseUrl,
publishDir: web.publishDir
};
ensurePublishSkillInstalled(web.publishDir, hintsContext);
ensureWorkspaceHintsInstalled(web.publishDir, hintsContext);
const legacyPublishDir = resolveLegacyPublishDir(h5Root, user);
if (legacyPublishDir && legacyPublishDir !== web.publishDir && fs8.existsSync(legacyPublishDir)) {
ensureWorkspaceHintsInstalled(legacyPublishDir, { ...hintsContext, publishDir: legacyPublishDir });
}
ensureUserMemoryProfile(web.publishDir, {
userId: user.id,
displayName: user.displayName ?? user.display_name,
username: user.username ?? web.username,
slug: web.slug
});
return {
...web,
...space,
publishDir: web.publishDir,
constraints: web.constraints
};
};
const listSkillGrants = async (subjectType, subjectId) => {
const [rows] = await pool2.query(
`SELECT skill_name, enabled
FROM h5_user_skill_grants
WHERE subject_type = ? AND subject_id = ?`,
[subjectType, subjectId]
);
return Object.fromEntries(rows.map((row) => [row.skill_name, Boolean(row.enabled)]));
};
const resolveUserSkillMap = async (user) => {
if (!user || user.role === "admin") {
return Object.fromEntries(skillCatalog.map((item) => [item.name, true]));
}
const roleDefaults = await listSkillGrants("role", "user");
const userOverrides = await listSkillGrants("user", user.id);
return resolveSkillMap(roleDefaults, userOverrides, skillCatalog);
};
const syncUserSkillsForUser = async (user) => {
if (!user || user.role === "admin") return;
const layout = await syncUserPublishWorkspace(user);
const skillMap = await resolveUserSkillMap(user);
syncSkillsToWorkspace({
h5Root,
publishDir: layout?.publishDir,
skillMap,
catalog: skillCatalog,
user,
publicBaseUrl
});
};
const syncUserPublishWorkspace = async (user) => {
if (!user) return null;
const layout = await publishLayoutFor(user);
const current = path11.resolve(user.workspace_root);
const target = path11.resolve(layout.publishDir);
if (current !== target) {
const now = Date.now();
await pool2.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [
layout.publishDir,
now,
user.id
]);
await pool2.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [user.id]);
await pool2.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
[user.id, layout.publishDir]
);
}
return layout;
};
const recordSignupBonus = async (conn, userId, amountCents, now) => {
const amount = Number(amountCents);
if (!Number.isFinite(amount) || amount <= 0) return;
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'adjust', ?, 0, '\u65B0\u7528\u6237\u8D60\u9001', NULL, ?)`,
[userId, amount, now]
);
};
const shouldEnableLowBalanceGift = ({ isAdmin = false, initialBalanceCents }) => {
return !isAdmin && Number(initialBalanceCents) === defaultSignupBalanceCents && defaultSignupBalanceCents > 0;
};
const grantLowBalanceGiftIfNeeded = async (conn, { userId, currentBalance, nextBalance, now }) => {
if (lowBalanceGiftAmountCents <= 0 || lowBalanceGiftThresholdCents < 0 || nextBalance > lowBalanceGiftThresholdCents) {
return { gifted: false, balanceAfter: nextBalance };
}
const [rows] = await conn.query(
`SELECT low_balance_gift_eligible, low_balance_gift_granted_at
FROM h5_users
WHERE id = ?
FOR UPDATE`,
[userId]
);
const user = rows[0];
if (!user || !Boolean(user.low_balance_gift_eligible) || user.low_balance_gift_granted_at != null) {
return { gifted: false, balanceAfter: nextBalance };
}
const giftedBalance = nextBalance + lowBalanceGiftAmountCents;
await conn.query(
`UPDATE h5_user_wallets
SET balance_cents = ?, updated_at = ?
WHERE user_id = ?`,
[giftedBalance, now, userId]
);
await conn.query(
`UPDATE h5_users
SET low_balance_gift_eligible = 0,
low_balance_gift_granted_at = ?,
status = CASE WHEN status = 'suspended' THEN 'active' ELSE status END,
updated_at = ?
WHERE id = ?`,
[now, now, userId]
);
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'adjust', ?, 0, ?, NULL, ?)`,
[userId, lowBalanceGiftAmountCents, "\u65B0\u7528\u6237\u4F4E\u4F59\u989D\u81EA\u52A8\u8D60\u9001", now]
);
await conn.query(
`INSERT INTO h5_user_notifications
(id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at)
VALUES (?, ?, 'web', 'low_balance_gift', ?, ?, ?, 'unread', NULL, ?, ?)`,
[
crypto5.randomUUID(),
userId,
"\u65B0\u7528\u6237\u989D\u5EA6\u5DF2\u81EA\u52A8\u8865\u9001",
`\u68C0\u6D4B\u5230\u4F60\u7684\u4F59\u989D\u5DF2\u4F4E\u4E8E \xA5${(lowBalanceGiftThresholdCents / 100).toFixed(2)}\uFF0C\u7CFB\u7EDF\u5DF2\u81EA\u52A8\u8D60\u9001 \xA5${(lowBalanceGiftAmountCents / 100).toFixed(2)} \u65B0\u7528\u6237\u989D\u5EA6\u3002\u672C\u798F\u5229\u4EC5\u53EF\u9886\u53D6\u4E00\u6B21\u3002`,
JSON.stringify({
triggerBalanceCents: nextBalance,
previousBalanceCents: currentBalance,
giftAmountCents: lowBalanceGiftAmountCents,
thresholdCents: lowBalanceGiftThresholdCents
}),
now,
now
]
);
return { gifted: true, balanceAfter: giftedBalance };
};
const register = async ({ username, password, displayName, email }) => {
const normalized = normalizeUsername(username);
if (!isValidUsername(normalized)) {
return { ok: false, message: "\u7528\u6237\u540D\u4EC5\u652F\u6301 2-32 \u4F4D\u5C0F\u5199\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u4E0B\u5212\u7EBF" };
}
if (!password || password.length < 6) {
return { ok: false, message: "\u5BC6\u7801\u81F3\u5C11 6 \u4F4D" };
}
if (!email || !isValidEmail(email.trim())) {
return { ok: false, message: "\u8BF7\u8F93\u5165\u6709\u6548\u90AE\u7BB1" };
}
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
const userId = crypto5.randomUUID();
const layout = await publishLayoutFor({ id: userId, username: normalized });
const workspaceRoot = layout.publishDir;
const now = Date.now();
const conn = await pool2.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_users
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 1, NULL, ?, ?)`,
[
userId,
normalized,
normalized,
email?.trim().toLowerCase() || null,
displayName?.trim() || normalized,
salt,
passwordHash,
passwordAlgorithm,
workspaceRoot,
now,
now
]
);
await conn.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)`,
[userId, defaultSignupBalanceCents, now]
);
await recordSignupBonus(conn, userId, defaultSignupBalanceCents, now);
await conn.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
[userId, workspaceRoot]
);
await initializeDefaultSpace(conn, userId, {
quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
now
});
await conn.commit();
ensureWorkspace(workspaceRoot);
ensureUserMemoryProfile(workspaceRoot, {
userId,
displayName: displayName?.trim() || normalized,
username: normalized,
slug: normalized
});
if (subscriptionService) {
subscriptionService.grantSubscription(userId, "free", null, null, "\u6CE8\u518C\u8D60\u9001\u514D\u8D39\u5957\u9910").catch(() => {
});
}
const user = await getUserById(userId);
return { ok: true, user: publicUser(user) };
} catch (err) {
await conn.rollback();
if (err?.code === "ER_DUP_ENTRY") {
return { ok: false, message: "\u7528\u6237\u540D\u3001\u4E3B\u9875\u5730\u5740\u6216\u90AE\u7BB1\u5DF2\u5B58\u5728" };
}
throw err;
} finally {
conn.release();
}
};
const login = async ({ username, password, ip = "unknown", now = Date.now() }) => {
pruneLoginFailures(now);
const normalized = normalizeUsername(username);
const failureKey = `${ip}:${normalized}`;
const failure = loginFailures.get(failureKey);
if (failure && failure.count >= loginMaxFailures && failure.resetAt > now) {
return {
ok: false,
message: "\u5C1D\u8BD5\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5",
retryAfterMs: failure.resetAt - now
};
}
const [rows] = await pool2.query(
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
u.plan_type, u.workspace_root,
u.salt, u.password_hash, u.password_algorithm, w.balance_cents, w.tokens_used
FROM h5_users u
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
WHERE u.username = ?`,
[normalized]
);
const row = rows[0];
if (!row) {
const current = failure && failure.resetAt > now ? failure : { count: 0, resetAt: now + loginFailureWindowMs };
current.count += 1;
loginFailures.set(failureKey, current);
return { ok: false, message: "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" };
}
if (!verifyPassword(password, row)) {
const current = failure && failure.resetAt > now ? failure : { count: 0, resetAt: now + loginFailureWindowMs };
current.count += 1;
loginFailures.set(failureKey, current);
return { ok: false, message: "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" };
}
if (row.status === "disabled") {
return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" };
}
if ((row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2) !== PASSWORD_ALGORITHM_ARGON2ID) {
const nextPassword = createPasswordRecord(password);
await pool2.query(
`UPDATE h5_users
SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ?
WHERE id = ?`,
[nextPassword.salt, nextPassword.passwordHash, nextPassword.passwordAlgorithm, now, row.id]
);
row.salt = nextPassword.salt;
row.password_hash = nextPassword.passwordHash;
row.password_algorithm = nextPassword.passwordAlgorithm;
}
loginFailures.delete(failureKey);
const token = crypto5.randomBytes(32).toString("base64url");
await storeSession(row.id, row.role, token, now);
return { ok: true, token, user: publicUser(row) };
};
const resetPassword = async ({ username, email, password }) => {
const normalized = normalizeUsername(username);
if (!isValidUsername(normalized)) {
return { ok: false, message: "\u7528\u6237\u540D\u6216\u90AE\u7BB1\u4E0D\u6B63\u786E" };
}
if (!email || !isValidEmail(email.trim())) {
return { ok: false, message: "\u8BF7\u8F93\u5165\u6709\u6548\u90AE\u7BB1" };
}
if (!password || password.length < 6) {
return { ok: false, message: "\u65B0\u5BC6\u7801\u81F3\u5C11 6 \u4F4D" };
}
const [rows] = await pool2.query(
`SELECT id, email, status FROM h5_users WHERE username = ? LIMIT 1`,
[normalized]
);
const row = rows[0];
const normalizedEmail = email.trim().toLowerCase();
if (!row || (row.email ?? "").toLowerCase() !== normalizedEmail) {
return { ok: false, message: "\u7528\u6237\u540D\u6216\u90AE\u7BB1\u4E0D\u6B63\u786E" };
}
if (row.status === "disabled") {
return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" };
}
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
const now = Date.now();
await pool2.query(
`UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`,
[salt, passwordHash, passwordAlgorithm, now, row.id]
);
await revokeAllSessionsForUser(row.id, now);
return { ok: true };
};
const verify = async (token, now = Date.now()) => {
if (!token) return null;
pruneSessions(now);
const cached = sessions.get(token);
if (cached) {
if (cached.expiresAt <= now) {
sessions.delete(token);
return null;
}
const user = await getUserById(cached.userId);
if (!user || user.status === "disabled") {
await revoke(token, now);
return null;
}
cached.expiresAt = now + sessionTtlMs;
if (persistSessions) {
await pool2.query(
`UPDATE h5_login_sessions
SET expires_at = ?
WHERE token_hash = ? AND revoked_at IS NULL`,
[cached.expiresAt, hashSessionToken(token)]
);
}
return cached;
}
if (!persistSessions) return null;
const tokenHash = hashSessionToken(token);
const [rows] = await pool2.query(
`SELECT s.user_id, s.expires_at, u.role, u.status
FROM h5_login_sessions s
JOIN h5_users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.revoked_at IS NULL
LIMIT 1`,
[tokenHash]
);
const row = rows[0];
if (!row || Number(row.expires_at ?? 0) <= now || row.status === "disabled") {
if (row) await revoke(token, now);
return null;
}
const expiresAt = now + sessionTtlMs;
await pool2.query(
`UPDATE h5_login_sessions SET expires_at = ? WHERE token_hash = ? AND revoked_at IS NULL`,
[expiresAt, tokenHash]
);
const session = { userId: row.user_id, role: row.role, expiresAt };
sessions.set(token, session);
return session;
};
const revoke = async (token, now = Date.now()) => {
if (!token) return;
sessions.delete(token);
if (!persistSessions) return;
await pool2.query(
`UPDATE h5_login_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`,
[now, hashSessionToken(token)]
);
};
const getMe = async (token) => {
const session = await verify(token);
if (!session) return null;
const user = await getUserById(session.userId);
if (!user) return null;
return publicUser(user);
};
const listPathGrants = async (userId) => {
const [rows] = await pool2.query(
`SELECT path, mode FROM h5_user_path_grants WHERE user_id = ? ORDER BY path`,
[userId]
);
return rows.map((row) => ({ path: row.path, mode: row.mode }));
};
const resolveWorkingDir = async (userId) => {
const user = await getUserById(userId);
if (!user) throw new Error("\u7528\u6237\u4E0D\u5B58\u5728");
const layout = await syncUserPublishWorkspace(user);
return layout.publishDir;
};
const getUserPublishLayout = async (userId) => {
const user = await getUserById(userId);
if (!user) return null;
return syncUserPublishWorkspace(user);
};
const isPathAllowed = async (userId, requestedPath) => {
const user = await getUserById(userId);
if (!user) return false;
if (isAdminRole(user)) return true;
const layout = await publishLayoutFor(user, { migrateLegacy: false });
return isPathInsideUserWorkspace(layout.publishDir, requestedPath);
};
const repairAllUserPublishDirs = async () => {
const [rows] = await pool2.query(
`SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'`
);
for (const row of rows) {
await syncUserSkillsForUser(row);
}
};
const seedRoleSkillDefaults = async () => {
const now = Date.now();
for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) {
await pool2.query(
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
VALUES ('role', 'user', ?, ?, ?)
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
[name, enabled ? 1 : 0, now]
);
}
};
const registerAgentSession = async (userId, agentSessionId, goosedTarget = 0) => {
const isLegacyIndex = typeof goosedTarget === "number" || /^\d+$/.test(String(goosedTarget));
const goosedNode = isLegacyIndex ? Number(goosedTarget) : 0;
const targetUrl = isLegacyIndex ? null : String(goosedTarget);
await pool2.query(
`INSERT INTO h5_user_sessions (agent_session_id, user_id, goosed_node, goosed_target, created_at)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id),
goosed_node = VALUES(goosed_node), goosed_target = VALUES(goosed_target)`,
[agentSessionId, userId, goosedNode, targetUrl, Date.now()]
);
};
const getSessionNode = async (agentSessionId) => {
const [rows] = await pool2.query(
`SELECT goosed_node FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
[agentSessionId]
);
return rows[0]?.goosed_node ?? 0;
};
const getSessionTarget = async (agentSessionId) => {
const [rows] = await pool2.query(
`SELECT goosed_node, goosed_target FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
[agentSessionId]
);
return { target: rows[0]?.goosed_target ?? null, node: rows[0]?.goosed_node ?? 0 };
};
const ownsSession = async (userId, agentSessionId) => {
const [rows] = await pool2.query(
`SELECT 1 FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ? LIMIT 1`,
[agentSessionId, userId]
);
return rows.length > 0;
};
const listOwnedSessionIds = async (userId) => {
const [rows] = await pool2.query(
`SELECT agent_session_id FROM h5_user_sessions WHERE user_id = ?`,
[userId]
);
return new Set(rows.map((row) => row.agent_session_id));
};
const setSessionOrigin = async (agentSessionId, origin) => {
if (!agentSessionId || origin !== "h5" && origin !== "wechat") return;
await pool2.query(
`UPDATE h5_user_sessions SET origin = ? WHERE agent_session_id = ?`,
[origin, agentSessionId]
);
};
const getSessionOrigins = async (agentSessionIds = []) => {
const ids = [...new Set(agentSessionIds)].filter(Boolean);
if (ids.length === 0) return /* @__PURE__ */ new Map();
const [rows] = await pool2.query(
`SELECT agent_session_id, origin FROM h5_user_sessions WHERE agent_session_id IN (?)`,
[ids]
);
return new Map(rows.map((row) => [row.agent_session_id, row.origin]));
};
const unregisterAgentSession = async (userId, agentSessionId) => {
await pool2.query(
`DELETE FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ?`,
[agentSessionId, userId]
);
};
const canUseChat = async (userId) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
if (user.status === "disabled") {
return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528" };
}
if (user.status === "suspended" && !isAdminRole(user)) {
return { ok: false, message: "\u8D26\u6237\u5DF2\u6682\u505C" };
}
if (isAdminRole(user)) {
return { ok: true, balanceCents: Number(user.balance_cents ?? 0) };
}
if (subscriptionService) {
const sub = await subscriptionService.getActiveSubscription(userId);
if (sub) {
const unlimited = sub.periodTokensLimit === 0;
const hasQuota = unlimited || sub.periodTokensUsed < sub.periodTokensLimit;
const balanceCents2 = Number(user.balance_cents ?? 0);
if (hasQuota) {
return { ok: true, balanceCents: balanceCents2, subscription: sub };
}
if (balanceCents2 > 0) {
return { ok: true, balanceCents: balanceCents2, subscription: sub, overQuota: true };
}
return {
ok: false,
message: "\u672C\u6708\u989D\u5EA6\u5DF2\u7528\u5B8C\uFF0C\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u6216\u5347\u7EA7\u5957\u9910",
...buildInsufficientBalancePayload(balanceCents2, loadRechargeConfig())
};
}
}
const balanceCents = Number(user.balance_cents ?? 0);
if (balanceCents <= 0) {
return {
ok: false,
message: "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u540E\u7EE7\u7EED\u4F7F\u7528",
...buildInsufficientBalancePayload(balanceCents, loadRechargeConfig())
};
}
return { ok: true, balanceCents };
};
const listUsers = async ({ page = 1, pageSize = 20, search = "", role = "", status = "" } = {}) => {
const safePageSize = Math.min(Math.max(Number(pageSize) || 20, 1), 100);
const safePage = Math.max(Number(page) || 1, 1);
const offset = (safePage - 1) * safePageSize;
const params = [];
const clauses = [];
if (search) {
clauses.push("(u.username LIKE ? OR u.display_name LIKE ?)");
params.push(`%${search}%`, `%${search}%`);
}
if (role) {
clauses.push("u.role = ?");
params.push(role);
}
if (status) {
clauses.push("u.status = ?");
params.push(status);
}
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const [[{ total }]] = await pool2.query(
`SELECT COUNT(*) AS total FROM h5_users u ${where}`,
params
);
const [rows] = await pool2.query(
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
u.plan_type, u.workspace_root,
s.quota_bytes, s.used_bytes, s.reserved_bytes,
u.created_at, u.updated_at, w.balance_cents, w.tokens_used
FROM h5_users u
LEFT JOIN h5_user_spaces s ON s.user_id = u.id
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
${where}
ORDER BY u.created_at DESC
LIMIT ${safePageSize} OFFSET ${offset}`,
params
);
return {
users: rows.map((row) => ({ ...publicUser(row), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) })),
total: Number(total),
page: safePage,
pageSize: safePageSize
};
};
const getUserPublic = async (userId) => {
const user = await getUserById(userId);
return user ? publicUser(user) : null;
};
const createUser = async ({
username,
password,
displayName,
workspaceRoot,
balanceCents,
role = "user",
email
}) => {
const normalized = normalizeUsername(username);
if (!isValidUsername(normalized)) {
return { ok: false, message: "\u7528\u6237\u540D\u683C\u5F0F\u65E0\u6548" };
}
if (!password || password.length < 6) {
return { ok: false, message: "\u5BC6\u7801\u81F3\u5C11 6 \u4F4D" };
}
const isAdmin = role === "admin";
const userId = crypto5.randomUUID();
const root2 = (await publishLayoutFor({ id: userId, username: normalized })).publishDir;
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
const now = Date.now();
const conn = await pool2.getConnection();
const initialBalanceCents = Number(balanceCents ?? defaultSignupBalanceCents);
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_users
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, NULL, ?, ?)`,
[
userId,
normalized,
normalized,
email?.trim().toLowerCase() || null,
displayName?.trim() || normalized,
salt,
passwordHash,
passwordAlgorithm,
isAdmin ? "admin" : "user",
root2,
shouldEnableLowBalanceGift({ isAdmin, initialBalanceCents }) ? 1 : 0,
now,
now
]
);
await conn.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)`,
[userId, initialBalanceCents, now]
);
await conn.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
[userId, root2]
);
if (!isAdmin) {
await initializeDefaultSpace(conn, userId, {
quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
now
});
}
await conn.commit();
ensureWorkspace(root2);
if (subscriptionService && !isAdmin) {
subscriptionService.grantSubscription(userId, "free", null, null, "\u6CE8\u518C\u8D60\u9001\u514D\u8D39\u5957\u9910").catch(() => {
});
}
const user = await getUserById(userId);
return { ok: true, user: publicUser(user) };
} catch (err) {
await conn.rollback();
if (err?.code === "ER_DUP_ENTRY") {
return { ok: false, message: "\u7528\u6237\u540D\u5DF2\u5B58\u5728" };
}
throw err;
} finally {
conn.release();
}
};
const updateUser = async (userId, patch) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
const now = Date.now();
const fields = [];
const values = [];
if (patch.displayName !== void 0) {
fields.push("display_name = ?");
values.push(patch.displayName.trim() || user.username);
}
if (patch.status !== void 0) {
fields.push("status = ?");
values.push(patch.status);
}
if (patch.workspaceRoot !== void 0) {
const root2 = path11.resolve(patch.workspaceRoot);
fields.push("workspace_root = ?");
values.push(root2);
ensureWorkspace(root2);
await pool2.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [userId]);
await pool2.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
[userId, root2]
);
}
if (patch.role !== void 0) {
fields.push("role = ?");
values.push(patch.role === "admin" ? "admin" : "user");
}
if (fields.length > 0) {
fields.push("updated_at = ?");
values.push(now, userId);
await pool2.query(`UPDATE h5_users SET ${fields.join(", ")} WHERE id = ?`, values);
}
if (patch.status === "disabled" || patch.status === "suspended") {
await revokeAllSessionsForUser(userId, now);
}
if (patch.balanceCents !== void 0) {
await pool2.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)
ON DUPLICATE KEY UPDATE balance_cents = VALUES(balance_cents), updated_at = VALUES(updated_at)`,
[userId, Number(patch.balanceCents), now]
);
}
if (patch.spaceQuotaBytes !== void 0) {
const quotaBytes = Math.floor(Number(patch.spaceQuotaBytes));
if (!Number.isFinite(quotaBytes) || quotaBytes <= 0) {
return { ok: false, message: "\u7A7A\u95F4\u5927\u5C0F\u65E0\u6548" };
}
const [spaceRows] = await pool2.query(
`SELECT id, quota_bytes, used_bytes, reserved_bytes
FROM h5_user_spaces
WHERE user_id = ?
LIMIT 1`,
[userId]
);
const currentSpace = spaceRows[0];
const occupiedBytes = Number(currentSpace?.used_bytes ?? 0) + Number(currentSpace?.reserved_bytes ?? 0);
if (quotaBytes < occupiedBytes) {
return {
ok: false,
message: `\u7A7A\u95F4\u4E0D\u80FD\u5C0F\u4E8E\u5DF2\u4F7F\u7528\u5BB9\u91CF ${Math.ceil(occupiedBytes / 1024 / 1024)} MB`
};
}
if (currentSpace?.id) {
await pool2.query(
`UPDATE h5_user_spaces
SET quota_bytes = ?, updated_at = ?
WHERE user_id = ?`,
[quotaBytes, now, userId]
);
} else {
await initializeDefaultSpace(pool2, userId, {
quotaBytes,
now
});
}
}
const updated = await getUserById(userId);
return { ok: true, user: publicUser(updated) };
};
const purchaseSpaceQuota = async (userId, sizeMb) => {
const purchaseMb = Math.floor(Number(sizeMb));
if (!Number.isFinite(purchaseMb) || purchaseMb <= 0) {
return { ok: false, message: "\u6269\u5BB9\u5927\u5C0F\u65E0\u6548" };
}
const deltaBytes = purchaseMb * 1024 * 1024;
const costCents = purchaseMb * 200;
const now = Date.now();
const conn = await pool2.getConnection();
try {
await conn.beginTransaction();
const [spaceRows] = await conn.query(
`SELECT id, quota_bytes, used_bytes, reserved_bytes
FROM h5_user_spaces
WHERE user_id = ?
LIMIT 1
FOR UPDATE`,
[userId]
);
if (!spaceRows[0]) {
await initializeDefaultSpace(conn, userId, { now });
}
const [walletRows] = await conn.query(
`SELECT balance_cents
FROM h5_user_wallets
WHERE user_id = ?
FOR UPDATE`,
[userId]
);
const balanceCents = Number(walletRows[0]?.balance_cents ?? 0);
if (balanceCents < costCents) {
await conn.rollback();
return {
ok: false,
code: "INSUFFICIENT_BALANCE",
message: "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5148\u5145\u503C\u540E\u518D\u8D2D\u4E70\u7A7A\u95F4",
balanceCents,
minRechargeCents: Math.max(500, costCents - balanceCents),
suggestedTiers: loadRechargeConfig().tiersCents
};
}
await conn.query(
`UPDATE h5_user_wallets
SET balance_cents = balance_cents - ?, updated_at = ?
WHERE user_id = ?`,
[costCents, now, userId]
);
await conn.query(
`UPDATE h5_user_spaces
SET quota_bytes = quota_bytes + ?, updated_at = ?
WHERE user_id = ?`,
[deltaBytes, now, userId]
);
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
[userId, costCents, `space_purchase:${purchaseMb}MB`, now]
);
await conn.query(
`INSERT INTO h5_user_notifications
(id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at)
VALUES (?, ?, 'web', 'space_purchase', ?, ?, ?, 'unread', NULL, ?, ?)`,
[
crypto5.randomUUID(),
userId,
"\u7A7A\u95F4\u6269\u5BB9\u6210\u529F",
`\u5DF2\u8D2D\u4E70 ${purchaseMb} MB \u7A7A\u95F4\uFF0C\u652F\u4ED8 \xA5${(costCents / 100).toFixed(2)}\u3002`,
JSON.stringify({ purchaseMb, deltaBytes, costCents }),
now,
now
]
);
await conn.commit();
const [updatedSpaceRows] = await pool2.query(
`SELECT quota_bytes, used_bytes, reserved_bytes
FROM h5_user_spaces
WHERE user_id = ?
LIMIT 1`,
[userId]
);
const updatedSpace = updatedSpaceRows[0] ?? {};
const updatedUser = await getUserById(userId);
return {
ok: true,
balanceCents: Number(updatedUser?.balance_cents ?? Math.max(0, balanceCents - costCents)),
quota: {
quotaBytes: Number(updatedSpace.quota_bytes ?? 0),
usedBytes: Number(updatedSpace.used_bytes ?? 0),
reservedBytes: Number(updatedSpace.reserved_bytes ?? 0),
availableBytes: Math.max(
0,
Number(updatedSpace.quota_bytes ?? 0) - Number(updatedSpace.used_bytes ?? 0) - Number(updatedSpace.reserved_bytes ?? 0)
)
}
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
};
const recharge = async (userId, amountCents, operatorId, note = "", options2 = {}) => {
const amount = Number(amountCents);
if (!Number.isFinite(amount) || amount <= 0) {
return { ok: false, message: "\u5145\u503C\u91D1\u989D\u65E0\u6548" };
}
const user = await getUserById(userId);
if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
const paymentOrderId = options2.paymentOrderId ?? null;
const ledgerNote = paymentOrderId ? `order:${paymentOrderId}` : note || (operatorId ? "\u7BA1\u7406\u5458\u5145\u503C" : "\u8D26\u6237\u5145\u503C");
const rechargeType = paymentOrderId ? "self_recharge" : operatorId ? "admin_recharge" : "recharge";
const now = Date.now();
const ownsConnection = !options2.conn;
const conn = options2.conn ?? await pool2.getConnection();
try {
if (ownsConnection) await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)
ON DUPLICATE KEY UPDATE
balance_cents = balance_cents + VALUES(balance_cents),
updated_at = VALUES(updated_at)`,
[userId, amount, now]
);
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'recharge', ?, 0, ?, ?, ?)`,
[userId, amount, ledgerNote, operatorId, now]
);
const title = paymentOrderId ? "\u5145\u503C\u6210\u529F" : operatorId ? "\u7BA1\u7406\u5458\u5DF2\u5145\u503C" : "\u8D26\u6237\u5145\u503C\u6210\u529F";
const body = paymentOrderId ? `\u4F60\u5DF2\u6210\u529F\u5145\u503C \xA5${(amount / 100).toFixed(2)}\uFF0C\u4F59\u989D\u5DF2\u66F4\u65B0\u3002` : operatorId ? `\u7BA1\u7406\u5458\u5DF2\u4E3A\u4F60\u5145\u503C \xA5${(amount / 100).toFixed(2)}\uFF0C\u4F59\u989D\u5DF2\u66F4\u65B0\u3002` : `\u4F60\u7684\u8D26\u6237\u5DF2\u5145\u503C \xA5${(amount / 100).toFixed(2)}\uFF0C\u4F59\u989D\u5DF2\u66F4\u65B0\u3002`;
await conn.query(
`INSERT INTO h5_user_notifications
(id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at)
VALUES (?, ?, 'web', ?, ?, ?, ?, 'unread', NULL, ?, ?)`,
[
crypto5.randomUUID(),
userId,
rechargeType,
title,
body,
JSON.stringify({
amountCents: amount,
operatorId: operatorId ?? null,
paymentOrderId
}),
now,
now
]
);
if (user.status === "suspended") {
await conn.query(`UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ?`, [
now,
userId
]);
}
if (ownsConnection) await conn.commit();
const updated = await getUserById(userId);
if (rechargeNotifier) {
try {
await rechargeNotifier({
userId,
amountCents: amount,
operatorId: operatorId ?? null,
paymentOrderId,
dedupeKey: paymentOrderId ? `recharge:${paymentOrderId}` : null,
notificationType: rechargeType,
title,
body,
user: publicUser(updated)
});
} catch (err) {
console.warn(
"Recharge notifier failed:",
err instanceof Error ? err.message : String(err)
);
}
}
return { ok: true, user: publicUser(updated) };
} catch (err) {
if (ownsConnection) await conn.rollback();
throw err;
} finally {
if (ownsConnection) conn.release();
}
};
const getBillingState = async (agentSessionId) => {
const [rows] = await pool2.query(
`SELECT agent_session_id, user_id, last_accumulated_cost, last_input_tokens,
last_output_tokens, updated_at
FROM h5_session_billing_state
WHERE agent_session_id = ?`,
[agentSessionId]
);
const row = rows[0];
if (!row) return null;
return {
agentSessionId: row.agent_session_id,
userId: row.user_id,
lastAccumulatedCost: row.last_accumulated_cost,
lastInputTokens: Number(row.last_input_tokens ?? 0),
lastOutputTokens: Number(row.last_output_tokens ?? 0),
updatedAt: Number(row.updated_at)
};
};
const billSessionUsage = async (userId, agentSessionId, tokenStateRaw, requestId = null) => {
const user = await getUserById(userId);
if (isAdminRole(user)) {
return {
ok: true,
costCents: 0,
balanceCents: Number(user?.balance_cents ?? 0),
tokensUsed: Number(user?.tokens_used ?? 0),
deltaInputTokens: 0,
deltaOutputTokens: 0
};
}
const tokenState = normalizeTokenState(tokenStateRaw);
const config = loadBillingConfig();
const normalizedRequestId = requestId ? String(requestId).trim() || null : null;
const now = Date.now();
const conn = await pool2.getConnection();
try {
await conn.beginTransaction();
if (normalizedRequestId) {
const [existingUsage] = await conn.query(
`SELECT cost_cents FROM h5_usage_records WHERE request_id = ? LIMIT 1`,
[normalizedRequestId]
);
if (existingUsage[0]) {
const [walletRows] = await conn.query(
`SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ?`,
[userId]
);
await conn.commit();
return {
ok: true,
costCents: 0,
balanceCents: walletRows[0] ? Number(walletRows[0].balance_cents) : null,
tokensUsed: walletRows[0] ? Number(walletRows[0].tokens_used ?? 0) : null,
deltaInputTokens: 0,
deltaOutputTokens: 0
};
}
}
await conn.query(
`INSERT INTO h5_session_billing_state
(agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at)
VALUES (?, ?, NULL, 0, 0, ?)
ON DUPLICATE KEY UPDATE agent_session_id = agent_session_id`,
[agentSessionId, userId, now]
);
const [stateRows] = await conn.query(
`SELECT last_accumulated_cost, last_input_tokens, last_output_tokens
FROM h5_session_billing_state
WHERE agent_session_id = ?
FOR UPDATE`,
[agentSessionId]
);
const stateRow = stateRows[0];
const previous = stateRow ? {
lastAccumulatedCost: stateRow.last_accumulated_cost,
lastInputTokens: Number(stateRow.last_input_tokens ?? 0),
lastOutputTokens: Number(stateRow.last_output_tokens ?? 0)
} : null;
if (previous && tokenState.accumulatedInputTokens <= Number(previous.lastInputTokens ?? 0) && tokenState.accumulatedOutputTokens <= Number(previous.lastOutputTokens ?? 0)) {
const [walletRows] = await conn.query(
`SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ?`,
[userId]
);
await conn.commit();
return {
ok: true,
costCents: 0,
balanceCents: walletRows[0] ? Number(walletRows[0].balance_cents) : null,
tokensUsed: walletRows[0] ? Number(walletRows[0].tokens_used ?? 0) : null,
deltaInputTokens: 0,
deltaOutputTokens: 0
};
}
let costCents = computeDeltaCostCents(previous, tokenState, config);
const deltaIn = Math.max(
0,
tokenState.accumulatedInputTokens - Number(previous?.lastInputTokens ?? 0)
);
const deltaOut = Math.max(
0,
tokenState.accumulatedOutputTokens - Number(previous?.lastOutputTokens ?? 0)
);
const deltaTokens = deltaIn + deltaOut;
await conn.query(
`INSERT INTO h5_session_billing_state
(agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_accumulated_cost = VALUES(last_accumulated_cost),
last_input_tokens = VALUES(last_input_tokens),
last_output_tokens = VALUES(last_output_tokens),
updated_at = VALUES(updated_at)`,
[
agentSessionId,
userId,
tokenState.accumulatedCost,
tokenState.accumulatedInputTokens,
tokenState.accumulatedOutputTokens,
now
]
);
if (costCents > 0 && subscriptionService) {
const coverage = await subscriptionService.consumeQuota(userId, deltaTokens, conn);
if (coverage.fullyCovers) {
costCents = 0;
} else if (coverage.overageRate < 1) {
costCents = Math.max(1, Math.ceil(costCents * coverage.overageRate));
}
}
let balanceAfter = null;
let tokensUsedAfter = null;
if (costCents > 0) {
const [walletRows] = await conn.query(
`SELECT w.balance_cents, w.tokens_used, u.status
FROM h5_user_wallets w
JOIN h5_users u ON u.id = w.user_id
WHERE w.user_id = ?
FOR UPDATE`,
[userId]
);
const wallet = walletRows[0];
if (!wallet) {
await conn.rollback();
return { ok: false, message: "\u94B1\u5305\u4E0D\u5B58\u5728", costCents: 0 };
}
const currentBalance = Number(wallet.balance_cents ?? 0);
const nextBalance = Math.max(0, currentBalance - costCents);
balanceAfter = nextBalance;
tokensUsedAfter = Number(wallet.tokens_used ?? 0) + deltaTokens;
await conn.query(
`UPDATE h5_user_wallets
SET balance_cents = ?, tokens_used = tokens_used + ?, updated_at = ?
WHERE user_id = ?`,
[nextBalance, deltaTokens, now, userId]
);
await conn.query(
`INSERT INTO h5_usage_records
(user_id, agent_session_id, request_id, input_tokens, output_tokens, cost_cents, balance_after_cents, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
userId,
agentSessionId,
normalizedRequestId,
deltaIn,
deltaOut,
costCents,
nextBalance,
now
]
);
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, session_id, note, operator_id, created_at)
VALUES (?, 'deduct', ?, ?, ?, ?, NULL, ?)`,
[
userId,
-costCents,
deltaTokens,
agentSessionId,
`\u5BF9\u8BDD\u6263\u8D39 input=${deltaIn} output=${deltaOut}`,
now
]
);
const lowBalanceGift = await grantLowBalanceGiftIfNeeded(conn, {
userId,
currentBalance,
nextBalance,
now
});
balanceAfter = lowBalanceGift.balanceAfter;
if (balanceAfter <= 0) {
await conn.query(`UPDATE h5_users SET status = 'suspended', updated_at = ? WHERE id = ?`, [
now,
userId
]);
}
} else {
const user2 = await getUserById(userId);
balanceAfter = user2 ? Number(user2.balance_cents) : null;
tokensUsedAfter = user2 ? Number(user2.tokens_used ?? 0) : null;
}
await conn.commit();
return {
ok: true,
costCents,
balanceCents: balanceAfter,
tokensUsed: tokensUsedAfter,
deltaInputTokens: deltaIn,
deltaOutputTokens: deltaOut
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
};
const listUsageRecords = async ({ userId = null, page = 1, pageSize = 20, limit = null } = {}) => {
if (limit !== null) {
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
const params2 = [];
const where2 = userId ? "WHERE r.user_id = ?" : "";
if (userId) params2.push(userId);
const [rows2] = await pool2.query(
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id
${where2} ORDER BY r.created_at DESC LIMIT ${safeLimit}`,
params2
);
return rows2.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) }));
}
const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
const safePage = Math.max(Number(page) || 1, 1);
const offset = (safePage - 1) * safePageSize;
const params = [];
const where = userId ? "WHERE r.user_id = ?" : "";
if (userId) params.push(userId);
const [[{ total }]] = await pool2.query(
`SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`,
params
);
const [rows] = await pool2.query(
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id
${where}
ORDER BY r.created_at DESC
LIMIT ${safePageSize} OFFSET ${offset}`,
params
);
return {
records: rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) })),
total: Number(total),
page: safePage,
pageSize: safePageSize
};
};
const listBillingLedger = async ({ userId = null, page = 1, pageSize = 20, limit = null, types = null } = {}) => {
const buildWhere = (params) => {
const clauses = [];
if (userId) {
clauses.push("l.user_id = ?");
params.push(userId);
}
if (Array.isArray(types) && types.length) {
clauses.push(`l.type IN (${types.map(() => "?").join(", ")})`);
params.push(...types);
}
return clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
};
const mapRow = (row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, type: row.type, amountCents: Number(row.amount_cents), tokens: Number(row.tokens), sessionId: row.session_id, note: row.note, createdAt: Number(row.created_at) });
if (limit !== null) {
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
const params = [];
const where2 = buildWhere(params);
const [rows2] = await pool2.query(`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens, l.session_id, l.note, l.created_at FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id ${where2} ORDER BY l.created_at DESC LIMIT ${safeLimit}`, params);
return rows2.map(mapRow);
}
const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
const safePage = Math.max(Number(page) || 1, 1);
const offset = (safePage - 1) * safePageSize;
const countParams = [];
const where = buildWhere(countParams);
const [[{ total }]] = await pool2.query(
`SELECT COUNT(*) AS total FROM h5_billing_ledger l ${where}`,
countParams
);
const dataParams = [];
const dataWhere = buildWhere(dataParams);
const [rows] = await pool2.query(
`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens,
l.session_id, l.note, l.created_at
FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id
${dataWhere}
ORDER BY l.created_at DESC
LIMIT ${safePageSize} OFFSET ${offset}`,
dataParams
);
return { entries: rows.map(mapRow), total: Number(total), page: safePage, pageSize: safePageSize };
};
const getAdminSummary = async () => {
const since24h = Date.now() - 24 * 60 * 60 * 1e3;
const [userRows] = await pool2.query(
`SELECT u.id, u.username, u.display_name, u.role, u.status,
COALESCE(w.balance_cents, 0) AS balance_cents
FROM h5_users u
LEFT JOIN h5_user_wallets w ON w.user_id = u.id`
);
let total = 0;
let active = 0;
let lowBalance = 0;
let totalBalanceCents = 0;
const lowBalanceUsers = [];
for (const row of userRows) {
total += 1;
if (row.status === "active") active += 1;
const balanceCents = Number(row.balance_cents);
totalBalanceCents += balanceCents;
if (row.role === "user" && balanceCents <= 0) {
lowBalance += 1;
if (lowBalanceUsers.length < 8) {
lowBalanceUsers.push({
id: row.id,
username: row.username,
displayName: row.display_name,
balanceCents
});
}
}
}
const [[usage24h]] = await pool2.query(
`SELECT COUNT(*) AS count, COALESCE(SUM(cost_cents), 0) AS cost_cents
FROM h5_usage_records
WHERE created_at >= ?`,
[since24h]
);
const recentUsage = await listUsageRecords({ limit: 8 });
const recentLedger = await listBillingLedger({ limit: 8 });
return {
users: { total, active, lowBalance, totalBalanceCents },
usage24h: {
count: Number(usage24h.count),
costCents: Number(usage24h.cost_cents)
},
lowBalanceUsers,
recentUsage,
recentLedger
};
};
const syncAdminPassword = async () => {
const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? "admin");
const adminPassword = process.env.H5_ADMIN_PASSWORD;
if (!adminPassword) return;
const [rows] = await pool2.query(
`SELECT id FROM h5_users WHERE username = ? AND role = 'admin' LIMIT 1`,
[adminUsername]
);
if (rows.length === 0) return;
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(adminPassword);
const now = Date.now();
await pool2.query(
`UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`,
[salt, passwordHash, passwordAlgorithm, now, rows[0].id]
);
};
const seedRoleCapabilityDefaults = async () => {
const now = Date.now();
for (const [key, allowed] of Object.entries(DEFAULT_USER_CAPABILITIES)) {
await pool2.query(
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
VALUES ('role', 'user', ?, ?, ?)
ON DUPLICATE KEY UPDATE capability_key = capability_key`,
[key, allowed ? 1 : 0, now]
);
}
};
const upgradeMemoryStoreCapability = async () => {
const now = Date.now();
await pool2.query(
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
VALUES ('role', 'user', 'memory_store', 1, ?)
ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`,
[now]
);
};
const upgradeDefaultUserCapabilities = async () => {
const now = Date.now();
for (const key of ["skills", "chat_recall"]) {
if (!DEFAULT_USER_CAPABILITIES[key]) continue;
await pool2.query(
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
VALUES ('role', 'user', ?, 1, ?)
ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`,
[key, now]
);
}
};
const upgradeDefaultUserSkills = async () => {
const now = Date.now();
for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) {
if (!enabled) continue;
await pool2.query(
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
VALUES ('role', 'user', ?, 1, ?)
ON DUPLICATE KEY UPDATE enabled = 1, updated_at = VALUES(updated_at)`,
[name, now]
);
}
};
const serializePolicyValue = (key, value) => {
const def = POLICY_CATALOG.find((item) => item.key === key);
if (def?.type === "boolean") return value ? "true" : "false";
return String(value);
};
const parsePolicyValue = (key, raw) => {
const def = POLICY_CATALOG.find((item) => item.key === key);
if (def?.type === "boolean") return raw === "true" || raw === "1";
return raw;
};
const seedRolePolicyDefaults = async () => {
const now = Date.now();
for (const [key, value] of Object.entries(DEFAULT_USER_POLICIES)) {
await pool2.query(
`INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at)
VALUES ('role', 'user', ?, ?, ?)
ON DUPLICATE KEY UPDATE policy_key = policy_key`,
[key, serializePolicyValue(key, value), now]
);
}
};
const listPolicyEntries = async (subjectType, subjectId) => {
const [rows] = await pool2.query(
`SELECT policy_key, policy_value
FROM h5_user_policies
WHERE subject_type = ? AND subject_id = ?`,
[subjectType, subjectId]
);
return Object.fromEntries(
rows.map((row) => [row.policy_key, parsePolicyValue(row.policy_key, row.policy_value)])
);
};
const resolveUserPolicies = async (user) => {
if (!user || user.role === "admin") {
return { unrestricted: true, policies: {} };
}
const roleDefaults = await listPolicyEntries("role", "user");
const userOverrides = await listPolicyEntries("user", user.id);
return {
unrestricted: false,
policies: resolvePolicies(roleDefaults, userOverrides)
};
};
const listCapabilityGrants = async (subjectType, subjectId) => {
const [rows] = await pool2.query(
`SELECT capability_key, allowed
FROM h5_capability_grants
WHERE subject_type = ? AND subject_id = ?`,
[subjectType, subjectId]
);
return Object.fromEntries(
rows.map((row) => [row.capability_key, Boolean(row.allowed)])
);
};
const resolveUserCapabilities = async (user) => {
if (!user) return { unrestricted: true, capabilities: {} };
if (user.role === "admin") {
const skillMap2 = Object.fromEntries(skillCatalog.map((item) => [item.name, true]));
return {
unrestricted: true,
capabilities: Object.fromEntries(catalogKeys().map((key) => [key, true])),
skills: skillMap2,
grantedSkills: grantedSkillNames(skillMap2)
};
}
const roleDefaults = await listCapabilityGrants("role", "user");
const userOverrides = await listCapabilityGrants("user", user.id);
const capabilities = {};
for (const key of catalogKeys()) {
if (key in userOverrides) {
capabilities[key] = userOverrides[key];
} else if (key in roleDefaults) {
capabilities[key] = roleDefaults[key];
} else {
capabilities[key] = DEFAULT_USER_CAPABILITIES[key] ?? false;
}
}
const skillMap = await resolveUserSkillMap(user);
const withSkills = applySkillGrantsToCapabilities(capabilities, skillMap);
return {
unrestricted: false,
capabilities: clampUserCapabilities(withSkills),
skills: skillMap,
grantedSkills: grantedSkillNames(skillMap)
};
};
const getAgentSessionPolicy = async (userId, { toolMode = "chat" } = {}) => {
const user = await getUserById(userId);
if (!user) throw new Error("\u7528\u6237\u4E0D\u5B58\u5728");
const capabilityState = await resolveUserCapabilities(user);
const policyState = await resolveUserPolicies(user);
await syncUserSkillsForUser(user);
if (capabilityState.unrestricted) {
return {
...buildAgentExtensionPolicy(capabilityState.capabilities, { unrestricted: true, toolMode }),
policies: {},
unrestricted: true,
toolMode
};
}
const effectiveCapabilities = applyPoliciesToCapabilities(
capabilityState.capabilities,
policyState.policies
);
let sandboxMcp = null;
if (effectiveCapabilities.static_publish || effectiveCapabilities.private_data_space) {
try {
const layout = await publishLayoutFor(user, { migrateLegacy: false });
sandboxMcp = {
// When goosed runs in a container its filesystem is split from the portal's,
// so the host paths the portal would otherwise send (node binary, MCP script)
// are not resolvable. These env overrides let the portal send container-canonical
// paths instead. Unset (e.g. local dev, co-located native goosed) => fall back to
// the portal's own paths, so behavior is unchanged.
serverPath: resolveSandboxMcpServerPath(process.env.GOOSED_MCP_SERVER_PATH),
sandboxRoot: resolveGoosedSandboxRoot(h5Root, user),
userId: user.id,
nodeExecPath: process.env.GOOSED_MCP_NODE_PATH
};
} catch (err) {
console.warn("[getAgentSessionPolicy] sandbox MCP setup failed, falling back:", err?.message);
}
}
return {
...buildAgentExtensionPolicy(effectiveCapabilities, {
unrestricted: false,
policies: policyState.policies,
sandboxMcp,
toolMode
}),
capabilities: effectiveCapabilities,
policies: policyState.policies,
unrestricted: false,
toolMode
};
};
const getCodeAgentSessionPolicy = async (userId) => getAgentSessionPolicy(userId, { toolMode: "code" });
const getRoleCapabilities = async (role = "user") => {
const roleDefaults = await listCapabilityGrants("role", role);
const capabilities = {};
for (const key of catalogKeys()) {
capabilities[key] = key in roleDefaults ? roleDefaults[key] : DEFAULT_USER_CAPABILITIES[key] ?? false;
}
return { role, capabilities: clampUserCapabilities(capabilities) };
};
const setRoleCapabilities = async (role, patch) => {
if (role !== "user") {
return { ok: false, message: "\u4EC5\u652F\u6301\u914D\u7F6E\u666E\u901A\u7528\u6237\u89D2\u8272\u9ED8\u8BA4\u6743\u9650" };
}
const normalized = normalizeCapabilityPatch(patch);
const now = Date.now();
for (const [key, allowed] of Object.entries(normalized)) {
const effectiveAllowed = USER_NON_GRANTABLE_CAPABILITIES.has(key) ? false : allowed;
await pool2.query(
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
VALUES ('role', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`,
[role, key, effectiveAllowed ? 1 : 0, now]
);
}
return { ok: true, ...await getRoleCapabilities(role) };
};
const getUserCapabilities = async (userId) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
const resolved = await resolveUserCapabilities(user);
const overrides = await listCapabilityGrants("user", userId);
return {
ok: true,
userId,
role: user.role,
unrestricted: resolved.unrestricted,
capabilities: resolved.capabilities,
overrides
};
};
const setUserCapabilities = async (userId, patch) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
if (user.role === "admin") {
return { ok: false, message: "\u7BA1\u7406\u5458\u4E0D\u53D7\u80FD\u529B\u9650\u5236" };
}
const normalized = normalizeCapabilityPatch(patch);
const now = Date.now();
for (const [key, allowed] of Object.entries(normalized)) {
if (!isValidCapabilityKey(key)) continue;
const effectiveAllowed = USER_NON_GRANTABLE_CAPABILITIES.has(key) ? false : allowed;
await pool2.query(
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
VALUES ('user', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`,
[userId, key, effectiveAllowed ? 1 : 0, now]
);
}
return getUserCapabilities(userId);
};
const clearUserCapabilityOverrides = async (userId) => {
await pool2.query(
`DELETE FROM h5_capability_grants WHERE subject_type = 'user' AND subject_id = ?`,
[userId]
);
return getUserCapabilities(userId);
};
const getRolePolicies = async (role = "user") => {
const roleDefaults = await listPolicyEntries("role", role);
const policies = resolvePolicies(roleDefaults, {});
return { role, policies };
};
const setRolePolicies = async (role, patch) => {
if (role !== "user") {
return { ok: false, message: "\u4EC5\u652F\u6301\u914D\u7F6E\u666E\u901A\u7528\u6237\u89D2\u8272\u9ED8\u8BA4\u7B56\u7565" };
}
const normalized = normalizePolicyPatch(patch);
const now = Date.now();
for (const [key, value] of Object.entries(normalized)) {
await pool2.query(
`INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at)
VALUES ('role', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`,
[role, key, serializePolicyValue(key, value), now]
);
}
return { ok: true, ...await getRolePolicies(role) };
};
const getUserPolicies = async (userId) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
const policyState = await resolveUserPolicies(user);
const overrides = await listPolicyEntries("user", userId);
return {
ok: true,
userId,
role: user.role,
unrestricted: policyState.unrestricted,
policies: policyState.policies,
overrides
};
};
const setUserPolicies = async (userId, patch) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
if (user.role === "admin") {
return { ok: false, message: "\u7BA1\u7406\u5458\u4E0D\u53D7\u7B56\u7565\u9650\u5236" };
}
const normalized = normalizePolicyPatch(patch);
const now = Date.now();
for (const [key, value] of Object.entries(normalized)) {
if (!policyKeys().includes(key)) continue;
await pool2.query(
`INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at)
VALUES ('user', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`,
[userId, key, serializePolicyValue(key, value), now]
);
}
return getUserPolicies(userId);
};
const clearUserPolicyOverrides = async (userId) => {
await pool2.query(`DELETE FROM h5_user_policies WHERE subject_type = 'user' AND subject_id = ?`, [
userId
]);
return getUserPolicies(userId);
};
const getRoleSkills = async (role = "user") => {
const roleDefaults = await listSkillGrants("role", role);
const skills = resolveSkillMap(roleDefaults, {}, skillCatalog);
return { role, skills };
};
const setRoleSkills = async (role, patch) => {
if (role !== "user") {
return { ok: false, message: "\u4EC5\u652F\u6301\u914D\u7F6E\u666E\u901A\u7528\u6237\u89D2\u8272\u9ED8\u8BA4\u6280\u80FD" };
}
const normalized = normalizeSkillPatch(skillCatalog, patch);
const now = Date.now();
for (const [name, enabled] of Object.entries(normalized)) {
await pool2.query(
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
VALUES ('role', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
[role, name, enabled ? 1 : 0, now]
);
}
const [users] = await pool2.query(`SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'`);
for (const row of users) {
await syncUserSkillsForUser(row);
}
return { ok: true, ...await getRoleSkills(role) };
};
const getUserSkills = async (userId) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
const skills = await resolveUserSkillMap(user);
const overrides = await listSkillGrants("user", userId);
return {
ok: true,
userId,
role: user.role,
skills,
grantedSkills: grantedSkillNames(skills),
overrides
};
};
const setUserSkills = async (userId, patch) => {
const user = await getUserById(userId);
if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
if (user.role === "admin") {
return { ok: false, message: "\u7BA1\u7406\u5458\u4E0D\u53D7\u6280\u80FD\u9650\u5236" };
}
const normalized = normalizeSkillPatch(skillCatalog, patch);
const now = Date.now();
for (const [name, enabled] of Object.entries(normalized)) {
await pool2.query(
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
VALUES ('user', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
[userId, name, enabled ? 1 : 0, now]
);
}
await syncUserSkillsForUser(user);
return getUserSkills(userId);
};
const clearUserSkillOverrides = async (userId) => {
await pool2.query(`DELETE FROM h5_user_skill_grants WHERE subject_type = 'user' AND subject_id = ?`, [
userId
]);
const user = await getUserById(userId);
if (user) await syncUserSkillsForUser(user);
return getUserSkills(userId);
};
const ensureAdminUser = async () => {
const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? "admin");
const adminPassword = process.env.H5_ADMIN_PASSWORD;
const [rows] = await pool2.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
adminUsername
]);
if (rows.length === 0) {
if (!adminPassword) return;
await createUser({
username: adminUsername,
password: adminPassword,
displayName: "\u7BA1\u7406\u5458",
balanceCents: 999999999,
role: "admin"
});
} else {
const adminId = rows[0].id;
const now = Date.now();
const adminLayout = await publishLayoutFor({
id: adminId,
username: adminUsername,
displayName: "\u7BA1\u7406\u5458"
});
await pool2.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [
adminLayout.publishDir,
now,
adminId
]);
await pool2.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [adminId]);
await pool2.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
[adminId, adminLayout.publishDir]
);
await pool2.query(
`UPDATE h5_user_wallets SET balance_cents = GREATEST(balance_cents, ?), updated_at = ? WHERE user_id = ?`,
[999999999, now, adminId]
);
}
if (adminPassword) {
await syncAdminPassword();
}
await seedRoleCapabilityDefaults();
await upgradeMemoryStoreCapability();
await upgradeDefaultUserCapabilities();
await seedRolePolicyDefaults();
await seedRoleSkillDefaults();
await upgradeDefaultUserSkills();
await repairAllUserPublishDirs();
};
const PENDING_BIND_TTL_MS = 15 * 60 * 1e3;
const issueUserSession = async (userId, role, now = Date.now()) => {
const token = crypto5.randomBytes(32).toString("base64url");
await storeSession(userId, role, token, now);
return token;
};
const findBindingByOpenid = async (appId, openid) => {
const [rows] = await pool2.query(
`SELECT wi.user_id, u.status
FROM h5_user_wechat_identities wi
JOIN h5_users u ON u.id = wi.user_id
WHERE wi.app_id = ? AND wi.openid = ?
LIMIT 1`,
[appId, openid]
);
return rows[0] ?? null;
};
const findBindingByUnionid = async (unionid) => {
if (!unionid) return null;
const [rows] = await pool2.query(
`SELECT wi.user_id, wi.app_id, u.status
FROM h5_user_wechat_identities wi
JOIN h5_users u ON u.id = wi.user_id
WHERE wi.unionid = ?
LIMIT 1`,
[unionid]
);
return rows[0] ?? null;
};
const getWechatBindingForUser = async (userId, appId) => {
const [rows] = await pool2.query(
`SELECT id, nickname, avatar_url, last_login_at, created_at
FROM h5_user_wechat_identities
WHERE user_id = ? AND app_id = ?
LIMIT 1`,
[userId, appId]
);
return rows[0] ?? null;
};
const getWechatOpenidForUser = async (userId, appId) => {
const [rows] = await pool2.query(
`SELECT openid
FROM h5_user_wechat_identities
WHERE user_id = ? AND app_id = ?
LIMIT 1`,
[userId, appId]
);
return rows[0]?.openid ?? null;
};
const findWechatUserByOpenid = async (appId, openid) => {
const [rows] = await pool2.query(
`SELECT wi.user_id, wi.nickname, u.username, u.slug, u.display_name, u.status
FROM h5_user_wechat_identities wi
JOIN h5_users u ON u.id = wi.user_id
WHERE wi.app_id = ? AND wi.openid = ?
LIMIT 1`,
[appId, openid]
);
return rows[0] ? {
userId: rows[0].user_id,
status: rows[0].status,
nickname: rows[0].nickname,
username: rows[0].username,
slug: rows[0].slug,
displayName: rows[0].display_name
} : null;
};
const getWechatAgentRoute = async (appId, openid) => {
const [rows] = await pool2.query(
`SELECT id, user_id, agent_session_id, status, created_at, updated_at
FROM h5_wechat_agent_routes
WHERE app_id = ? AND openid = ?
LIMIT 1`,
[appId, openid]
);
const row = rows[0];
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
agentSessionId: row.agent_session_id,
status: row.status,
createdAt: Number(row.created_at ?? 0),
updatedAt: Number(row.updated_at ?? 0)
};
};
const upsertWechatAgentRoute = async ({
userId,
appId,
openid,
agentSessionId,
status = "active",
now = Date.now()
}) => {
const id = crypto5.randomUUID();
await pool2.query(
`INSERT INTO h5_wechat_agent_routes
(id, user_id, app_id, openid, agent_session_id, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
user_id = VALUES(user_id),
agent_session_id = VALUES(agent_session_id),
status = VALUES(status),
updated_at = VALUES(updated_at)`,
[id, userId, appId, openid, agentSessionId, status, now, now]
);
const route = await getWechatAgentRoute(appId, openid);
return route?.id ?? id;
};
const clearWechatAgentRoute = async (appId, openid) => {
await pool2.query(`DELETE FROM h5_wechat_agent_routes WHERE app_id = ? AND openid = ?`, [
appId,
openid
]);
};
const touchWechatAgentRoute = async (appId, openid, now = Date.now()) => {
if (!appId || !openid) return;
await pool2.query(
`UPDATE h5_wechat_agent_routes SET updated_at = ? WHERE app_id = ? AND openid = ?`,
[now, appId, openid]
);
};
const recordWechatMpMessage = async ({
appId,
openid,
msgId,
now = Date.now()
}) => {
if (!appId || !openid || !msgId) return { inserted: true };
const [result] = await pool2.query(
`INSERT IGNORE INTO h5_wechat_mp_messages
(app_id, openid, msg_id, status, created_at, updated_at)
VALUES (?, ?, ?, 'processing', ?, ?)`,
[appId, openid, String(msgId), now, now]
);
if (Number(result?.affectedRows ?? 0) > 0) return { inserted: true };
const retryCutoff = now - 10 * 60 * 1e3;
const [retryResult] = await pool2.query(
`UPDATE h5_wechat_mp_messages
SET status = 'processing', agent_session_id = NULL, updated_at = ?
WHERE app_id = ? AND openid = ? AND msg_id = ?
AND (status = 'failed' OR (status = 'processing' AND updated_at < ?))`,
[now, appId, openid, String(msgId), retryCutoff]
);
return {
inserted: Number(retryResult?.affectedRows ?? 0) > 0,
duplicate: Number(retryResult?.affectedRows ?? 0) === 0
};
};
const finishWechatMpMessage = async ({
appId,
openid,
msgId,
status = "done",
agentSessionId = null,
now = Date.now()
}) => {
if (!appId || !openid || !msgId) return;
const safeStatus = status === "failed" ? "failed" : "done";
await pool2.query(
`UPDATE h5_wechat_mp_messages
SET status = ?, agent_session_id = COALESCE(?, agent_session_id), updated_at = ?
WHERE app_id = ? AND openid = ? AND msg_id = ?`,
[safeStatus, agentSessionId, now, appId, openid, String(msgId)]
);
};
const insertWechatMpMessageDetail = async ({
appId,
openid,
userId = null,
msgId = null,
msgType,
displayText = "",
agentText = "",
mediaId = null,
mediaUrl = null,
mediaPublicUrl = null,
mediaFormat = null,
locationLat = null,
locationLng = null,
locationLabel = null,
linkUrl = null,
linkTitle = null,
rawXmlHash = null,
rawJson = null,
now = Date.now()
}) => {
if (!appId || !openid || !msgType) return null;
const id = crypto5.randomUUID();
await pool2.query(
`INSERT INTO h5_wechat_mp_message_details
(id, app_id, openid, user_id, msg_id, msg_type, display_text, agent_text,
media_id, media_url, media_public_url, media_format,
location_lat, location_lng, location_label,
link_url, link_title, raw_xml_hash, raw_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
appId,
openid,
userId,
msgId ? String(msgId) : null,
String(msgType),
displayText || null,
agentText || null,
mediaId,
mediaUrl,
mediaPublicUrl,
mediaFormat,
locationLat,
locationLng,
locationLabel,
linkUrl,
linkTitle,
rawXmlHash,
rawJson ? JSON.stringify(rawJson) : null,
now
]
);
return id;
};
const bindWechatToUser = async ({
userId,
appId,
openid,
unionid,
nickname,
avatarUrl,
now = Date.now()
}) => {
const existingOpenid = await findBindingByOpenid(appId, openid);
if (existingOpenid && existingOpenid.user_id !== userId) {
return { ok: false, message: "\u8BE5\u5FAE\u4FE1\u5DF2\u7ED1\u5B9A\u5176\u4ED6\u8D26\u53F7\uFF0C\u8BF7\u5148\u7528\u8BE5\u8D26\u53F7\u767B\u5F55" };
}
const existingUserBind = await getWechatBindingForUser(userId, appId);
if (existingUserBind) {
return { ok: false, message: "\u4F60\u7684\u8D26\u53F7\u5DF2\u7ED1\u5B9A\u5176\u4ED6\u5FAE\u4FE1\uFF0C\u9700\u5148\u89E3\u7ED1\u540E\u518D\u8BD5" };
}
try {
await pool2.query(
`INSERT INTO h5_user_wechat_identities
(id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
crypto5.randomUUID(),
userId,
appId,
openid,
unionid,
nickname,
avatarUrl,
now,
now,
now
]
);
return { ok: true };
} catch (err) {
if (err?.code === "ER_DUP_ENTRY") {
return { ok: false, message: "\u5FAE\u4FE1\u7ED1\u5B9A\u51B2\u7A81\uFF0C\u8BF7\u91CD\u8BD5" };
}
throw err;
}
};
const touchWechatIdentity = async ({
appId,
openid,
unionid,
nickname,
avatarUrl,
now = Date.now()
}) => {
await pool2.query(
`UPDATE h5_user_wechat_identities
SET nickname = COALESCE(?, nickname),
avatar_url = COALESCE(?, avatar_url),
unionid = COALESCE(?, unionid),
last_login_at = ?,
updated_at = ?
WHERE app_id = ? AND openid = ?`,
[nickname, avatarUrl, unionid, now, now, appId, openid]
);
};
const pruneWechatPendingBinds = async (now = Date.now()) => {
await pool2.query(`DELETE FROM h5_wechat_pending_binds WHERE expires_at <= ?`, [now]);
};
const createWechatPendingBind = async ({
appId,
openid,
unionid,
nickname,
avatarUrl,
returnTo = "/",
utmSource = null,
utmMedium = null,
utmCampaign = null,
now = Date.now()
}) => {
await pruneWechatPendingBinds(now);
const token = crypto5.randomBytes(24).toString("base64url");
await pool2.query(
`INSERT INTO h5_wechat_pending_binds
(token, app_id, openid, unionid, nickname, avatar_url, return_to,
utm_source, utm_medium, utm_campaign, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
token,
appId,
openid,
unionid,
nickname,
avatarUrl,
returnTo,
utmSource,
utmMedium,
utmCampaign,
now + PENDING_BIND_TTL_MS,
now
]
);
return token;
};
const getWechatPendingBind = async (token, now = Date.now()) => {
if (!token) return null;
await pruneWechatPendingBinds(now);
const [rows] = await pool2.query(
`SELECT token, app_id, openid, unionid, nickname, avatar_url, return_to,
utm_source, utm_medium, utm_campaign, expires_at
FROM h5_wechat_pending_binds
WHERE token = ?
LIMIT 1`,
[token]
);
const row = rows[0];
if (!row || Number(row.expires_at) <= now) return null;
return row;
};
const consumeWechatPendingBind = async (token) => {
await pool2.query(`DELETE FROM h5_wechat_pending_binds WHERE token = ?`, [token]);
};
const loginBoundWechatUser = async ({
userId,
appId,
openid,
unionid,
nickname,
avatarUrl,
now = Date.now()
}) => {
await touchWechatIdentity({ appId, openid, unionid, nickname, avatarUrl, now });
const user = await getUserById(userId);
if (!user || user.status === "disabled") {
return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" };
}
const token = await issueUserSession(user.id, user.role, now);
return { ok: true, token, user: publicUser(user), isNewUser: false };
};
const generateWechatUsername = async (openid) => {
const cleaned = String(openid).replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
const suffix = cleaned.slice(-8) || crypto5.randomBytes(4).toString("hex");
let candidate = `wx_${suffix}`.slice(0, 32);
if (!isValidUsername(candidate)) {
candidate = `wx_${crypto5.randomBytes(4).toString("hex")}`;
}
for (let attempt = 0; attempt < 8; attempt += 1) {
const [rows] = await pool2.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
candidate
]);
if (!rows[0]) return candidate;
candidate = `wx_${suffix.slice(0, Math.max(1, 8 - attempt))}${crypto5.randomBytes(2).toString("hex")}`.slice(
0,
32
);
}
return `wx_${crypto5.randomBytes(6).toString("hex")}`.slice(0, 32);
};
const registerViaWechat = async ({
appId,
openid,
unionid,
nickname,
avatarUrl,
now = Date.now()
}) => {
const normalized = await generateWechatUsername(openid);
const randomPassword = crypto5.randomBytes(24).toString("base64url");
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword);
const userId = crypto5.randomUUID();
const layout = await publishLayoutFor({ id: userId, username: normalized });
const workspaceRoot = layout.publishDir;
const displayName = nickname?.trim() || "\u5FAE\u4FE1\u7528\u6237";
const conn = await pool2.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_users
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
role, status, plan_type, workspace_root, signup_source, low_balance_gift_eligible,
low_balance_gift_granted_at, created_at, updated_at)
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 1, NULL, ?, ?)`,
[
userId,
normalized,
normalized,
displayName,
salt,
passwordHash,
passwordAlgorithm,
workspaceRoot,
now,
now
]
);
await conn.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)`,
[userId, defaultSignupBalanceCents, now]
);
await recordSignupBonus(conn, userId, defaultSignupBalanceCents, now);
await conn.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
[userId, workspaceRoot]
);
await conn.query(
`INSERT INTO h5_user_wechat_identities
(id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
crypto5.randomUUID(),
userId,
appId,
openid,
unionid,
nickname,
avatarUrl,
now,
now,
now
]
);
await initializeDefaultSpace(conn, userId, {
quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
now
});
await conn.commit();
ensureWorkspace(workspaceRoot);
ensureUserMemoryProfile(workspaceRoot, {
userId,
displayName,
username: normalized,
slug: normalized
});
if (subscriptionService) {
subscriptionService.grantSubscription(userId, "free", null, null, "\u6CE8\u518C\u8D60\u9001\u514D\u8D39\u5957\u9910").catch(() => {
});
}
const user = await getUserById(userId);
return { ok: true, user: publicUser(user) };
} catch (err) {
await conn.rollback();
if (err?.code === "ER_DUP_ENTRY") {
return { ok: false, message: "\u5FAE\u4FE1\u8D26\u53F7\u6CE8\u518C\u51B2\u7A81\uFF0C\u8BF7\u91CD\u8BD5" };
}
throw err;
} finally {
conn.release();
}
};
const resolveWechatAuth = async ({
appId,
openid,
unionid,
nickname,
avatarUrl,
intent = "login",
bindUserId = null,
returnTo = "/",
utmSource = null,
utmMedium = null,
utmCampaign = null,
now = Date.now()
}) => {
let binding = await findBindingByOpenid(appId, openid);
if (!binding && unionid) {
const unionBinding = await findBindingByUnionid(unionid);
if (unionBinding) {
const linked = await bindWechatToUser({
userId: unionBinding.user_id,
appId,
openid,
unionid,
nickname,
avatarUrl,
now
});
if (!linked.ok) return linked;
binding = { user_id: unionBinding.user_id, status: unionBinding.status };
}
}
if (binding) {
if (binding.status === "disabled") {
return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" };
}
return loginBoundWechatUser({
userId: binding.user_id,
appId,
openid,
unionid,
nickname,
avatarUrl,
now
}).then((result) => result.ok ? { ...result, action: "login" } : result);
}
if (intent === "bind" && bindUserId) {
const user = await getUserById(bindUserId);
if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
if (user.status === "disabled") {
return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" };
}
const bound = await bindWechatToUser({
userId: bindUserId,
appId,
openid,
unionid,
nickname,
avatarUrl,
now
});
if (!bound.ok) return bound;
const token = await issueUserSession(user.id, user.role, now);
return {
ok: true,
action: "login",
token,
user: publicUser(user),
isNewUser: false,
bound: true
};
}
if (intent === "register") {
const registered = await registerViaWechat({
appId,
openid,
unionid,
nickname,
avatarUrl,
now
});
if (!registered.ok) return registered;
const token = await issueUserSession(registered.user.id, registered.user.role, now);
return {
ok: true,
action: "login",
token,
user: registered.user,
isNewUser: true
};
}
const pendingToken = await createWechatPendingBind({
appId,
openid,
unionid,
nickname,
avatarUrl,
returnTo,
utmSource,
utmMedium,
utmCampaign,
now
});
return {
ok: true,
action: "binding_gate",
pendingToken,
wechatProfile: {
nickname: nickname ?? null,
avatarUrl: avatarUrl ?? null
},
returnTo,
utmSource,
utmMedium,
utmCampaign
};
};
const completeWechatRegister = async ({ pendingToken, now = Date.now() }) => {
const pending = await getWechatPendingBind(pendingToken, now);
if (!pending) {
return { ok: false, message: "\u7ED1\u5B9A\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u5FAE\u4FE1\u767B\u5F55" };
}
const registered = await registerViaWechat({
appId: pending.app_id,
openid: pending.openid,
unionid: pending.unionid,
nickname: pending.nickname,
avatarUrl: pending.avatar_url,
now
});
if (!registered.ok) return registered;
await consumeWechatPendingBind(pendingToken);
const token = await issueUserSession(registered.user.id, registered.user.role, now);
return {
ok: true,
token,
user: registered.user,
isNewUser: true,
returnTo: pending.return_to || "/",
utmSource: pending.utm_source,
utmMedium: pending.utm_medium,
utmCampaign: pending.utm_campaign
};
};
const completeWechatBindAccount = async ({
pendingToken,
username,
password,
ip = "unknown",
now = Date.now()
}) => {
const pending = await getWechatPendingBind(pendingToken, now);
if (!pending) {
return { ok: false, message: "\u7ED1\u5B9A\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u5FAE\u4FE1\u767B\u5F55" };
}
const loginResult = await login({ username, password, ip, now });
if (!loginResult.ok) return loginResult;
const bound = await bindWechatToUser({
userId: loginResult.user.id,
appId: pending.app_id,
openid: pending.openid,
unionid: pending.unionid,
nickname: pending.nickname,
avatarUrl: pending.avatar_url,
now
});
if (!bound.ok) return bound;
await consumeWechatPendingBind(pendingToken);
return {
ok: true,
token: loginResult.token,
user: loginResult.user,
isNewUser: false,
bound: true,
returnTo: pending.return_to || "/"
};
};
const getWechatBindingStatus = async (userId, appId) => {
const row = await getWechatBindingForUser(userId, appId);
if (!row) return { bound: false };
return {
bound: true,
nickname: row.nickname,
avatarUrl: row.avatar_url,
lastLoginAt: Number(row.last_login_at),
boundAt: Number(row.created_at)
};
};
const loginByWechat = async (params) => {
const result = await resolveWechatAuth({ ...params, intent: "login" });
if (!result.ok) return result;
if (result.action === "binding_gate") {
return { ok: false, message: "\u9700\u8981\u5B8C\u6210\u8D26\u53F7\u7ED1\u5B9A" };
}
return result;
};
return {
USER_COOKIE,
register,
login,
loginByWechat,
resolveWechatAuth,
completeWechatRegister,
completeWechatBindAccount,
getWechatPendingBind,
getWechatBindingStatus,
getWechatOpenidForUser,
setRechargeNotifier(callback) {
rechargeNotifier = typeof callback === "function" ? callback : null;
},
findWechatUserByOpenid,
getWechatAgentRoute,
upsertWechatAgentRoute,
clearWechatAgentRoute,
touchWechatAgentRoute,
recordWechatMpMessage,
finishWechatMpMessage,
insertWechatMpMessageDetail,
resetPassword,
verify,
revoke,
revokeAllSessionsForUser,
getMe,
listPathGrants,
resolveWorkingDir,
getUserPublishLayout,
isPathAllowed,
repairAllUserPublishDirs,
registerAgentSession,
getSessionNode,
getSessionTarget,
unregisterAgentSession,
ownsSession,
listOwnedSessionIds,
setSessionOrigin,
getSessionOrigins,
canUseChat,
getUserById,
getUserPublic,
listUsers,
createUser,
updateUser,
purchaseSpaceQuota,
recharge,
billSessionUsage,
listUsageRecords,
listBillingLedger,
getAdminSummary,
ensureAdminUser,
seedRoleCapabilityDefaults,
resolveUserCapabilities,
getAgentSessionPolicy,
getCodeAgentSessionPolicy,
getRoleCapabilities,
setRoleCapabilities,
getUserCapabilities,
setUserCapabilities,
clearUserCapabilityOverrides,
resolveUserPolicies,
getRolePolicies,
setRolePolicies,
getUserPolicies,
setUserPolicies,
clearUserPolicyOverrides,
getRoleSkills,
setRoleSkills,
getUserSkills,
setUserSkills,
clearUserSkillOverrides,
syncUserSkillsForUser,
capabilityCatalog: CAPABILITY_CATALOG,
policyCatalog: POLICY_CATALOG,
skillCatalog,
publicUser
};
}
// scripts/agent-run-worker.mjs
var root = path12.join(path12.dirname(fileURLToPath5(import.meta.url)), "..");
function loadEnvFile(filePath) {
if (!fs9.existsSync(filePath)) return;
for (const line of fs9.readFileSync(filePath, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
function parseTargets() {
const csv = String(process.env.TKMIND_API_TARGETS ?? "").trim();
if (csv) {
return csv.split(",").map((item) => item.trim()).filter(Boolean);
}
return [process.env.TKMIND_API_TARGET ?? "https://127.0.0.1:18006"];
}
function parseArgs(argv) {
const args2 = {
once: false,
status: false,
recoverStale: false,
applyRecovery: false,
runId: "",
pollMs: Number(process.env.MEMIND_AGENT_RUN_WORKER_POLL_MS ?? 1e3),
limit: Number(process.env.MEMIND_AGENT_RUN_WORKER_BATCH_SIZE ?? 1),
staleMs: Number(process.env.MEMIND_AGENT_RUN_STALE_RUNNING_MS ?? process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1e3)
};
for (let i = 0; i < argv.length; i += 1) {
const item = argv[i];
if (item === "--once") args2.once = true;
else if (item === "--status") args2.status = true;
else if (item === "--recover-stale") args2.recoverStale = true;
else if (item === "--apply-recovery") {
args2.recoverStale = true;
args2.applyRecovery = true;
} else if (item === "--run-id") args2.runId = String(argv[++i] ?? "").trim();
else if (item === "--poll-ms") args2.pollMs = Number(argv[++i] ?? args2.pollMs);
else if (item === "--limit") args2.limit = Number(argv[++i] ?? args2.limit);
else if (item === "--stale-ms") args2.staleMs = Number(argv[++i] ?? args2.staleMs);
else if (item === "--help" || item === "-h") args2.help = true;
}
return args2;
}
function printHelp() {
console.log([
"Usage:",
" node scripts/agent-run-worker.mjs [--once] [--status] [--run-id <id>] [--poll-ms 1000] [--limit 1]",
" node scripts/agent-run-worker.mjs --recover-stale [--apply-recovery] [--stale-ms 900000]",
"",
"Notes:",
" - Processes existing h5_agent_runs queued/retryable rows through the Tool Gateway queue.",
" - Regular dispatch automatically fails stale running rows older than MEMIND_AGENT_RUN_TIMEOUT_MS.",
" - --recover-stale is dry-run by default; --apply-recovery marks stale running rows failed.",
" - Does not create agent runs by itself.",
" - --run-id dispatches exactly one known run and avoids scanning the global queue.",
" - Set MEMIND_AGENT_RUN_AUTODISPATCH=0 on Portal only when this worker is ready to take over."
].join("\n"));
}
loadEnvFile(process.env.MEMIND_ENV_FILE || path12.join(root, ".env"));
loadEnvFile(path12.join(root, ".env.local"));
var args = parseArgs(process.argv.slice(2));
if (args.help) {
printHelp();
process.exit(0);
}
var pool = createDbPool();
var baseUserAuth = createUserAuth(pool, {
usersRoot: process.env.H5_USERS_ROOT ?? path12.join(root, "users"),
h5Root: root
});
var overrideWorkingDir = String(process.env.MEMIND_AGENT_RUN_WORKDIR_OVERRIDE ?? "").trim();
var overrideWorkingDirUserId = String(process.env.MEMIND_AGENT_RUN_WORKDIR_USER_ID ?? "").trim();
var userAuth = overrideWorkingDir ? {
...baseUserAuth,
async resolveWorkingDir(userId) {
if (!overrideWorkingDirUserId || overrideWorkingDirUserId === userId) {
return path12.resolve(overrideWorkingDir);
}
return baseUserAuth.resolveWorkingDir(userId);
}
} : baseUserAuth;
var llmProviderService = createLlmProviderService(pool, {
apiTarget: process.env.TKMIND_API_TARGET ?? "https://127.0.0.1:18006",
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? "local-dev-secret"
});
var toolGateway = createToolGateway({ llmProviderService });
var apiTargets = parseTargets();
var tkmindProxy = createTkmindProxy({
apiTarget: apiTargets[0],
apiTargets,
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? "local-dev-secret",
userAuth
});
var gateway = createAgentRunGateway({
pool,
userAuth,
tkmindProxy,
toolGateway,
autoDispatch: false,
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1e3)
});
var stopping = false;
process.on("SIGINT", () => {
stopping = true;
});
process.on("SIGTERM", () => {
stopping = true;
});
async function runOnce() {
let result = null;
if (args.runId) {
gateway.dispatchRun(args.runId);
result = {
considered: 1,
dispatched: 1,
runId: args.runId
};
} else {
result = await gateway.dispatchQueuedRuns({ limit: args.limit });
}
await waitForIdle();
console.log(JSON.stringify({
ok: true,
mode: args.status ? "status" : "dispatch",
runId: args.runId || null,
dispatched: args.status ? 0 : result.dispatched,
queue: await gateway.getQueueStatus()
}));
}
async function runStaleRecovery() {
const result = await gateway.recoverStaleRunningRuns({
staleMs: args.staleMs,
limit: args.limit,
dryRun: !args.applyRecovery,
reason: args.applyRecovery ? "manual_stale_recovery" : "manual_stale_recovery_dry_run"
});
console.log(JSON.stringify({
ok: true,
mode: args.applyRecovery ? "recover-stale-apply" : "recover-stale-dry-run",
recovery: result,
queue: await gateway.getQueueStatus()
}, null, 2));
}
async function waitForIdle() {
const startedAt = Date.now();
const maxWaitMs = Number(process.env.MEMIND_AGENT_RUN_WORKER_DRAIN_WAIT_MS ?? 20 * 60 * 1e3);
while (!stopping) {
const status = await gateway.getQueueStatus();
if (status.inFlight === 0 && status.pendingDispatches === 0) return;
if (Date.now() - startedAt > maxWaitMs) {
throw new Error(`agent run worker did not drain within ${maxWaitMs}ms`);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
var exitCode = 0;
try {
if (args.status) {
console.log(JSON.stringify({ ok: true, queue: await gateway.getQueueStatus() }, null, 2));
} else if (args.recoverStale) {
await runStaleRecovery();
} else if (args.once || args.runId) {
await runOnce();
} else {
console.log(JSON.stringify({ ok: true, worker: "agent-run-worker", pollMs: args.pollMs, limit: args.limit }));
while (!stopping) {
await runOnce();
await new Promise((resolve) => setTimeout(resolve, Math.max(200, args.pollMs)));
}
}
} catch (err) {
exitCode = 1;
console.error(err instanceof Error ? err.message : String(err));
} finally {
await pool.end().catch(() => {
});
if (args.status || args.once) process.exit(exitCode);
}