diff --git a/.env.example b/.env.example
index 846efc0..36ccdee 100644
--- a/.env.example
+++ b/.env.example
@@ -29,6 +29,11 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# Tool Gateway Queue v0(/agent/runs 后台任务队列,默认保守限流)。
# MEMIND_AGENT_RUN_QUEUE_CONCURRENCY=1
# MEMIND_AGENT_RUN_TIMEOUT_MS=900000
+# P6.1 外部 Tool Worker 接管入口。默认 Portal 内部 auto-dispatch 保持开启;
+# 只有外部 worker 验证就绪后,才把 Portal 设为 0 并独立运行 scripts/agent-run-worker.mjs。
+# MEMIND_AGENT_RUN_AUTODISPATCH=1
+# MEMIND_AGENT_RUN_WORKER_POLL_MS=1000
+# MEMIND_AGENT_RUN_WORKER_BATCH_SIZE=1
# ===== memind_adm(独立后台服务)=====
# 平台超管 /admin-api/* 与广场运营 /api/ops/v1/* 已从公网服务拆出,独立进程运行。
diff --git a/.runtime/portal/RUNBOOK.txt b/.runtime/portal/RUNBOOK.txt
index 343ae83..c260cad 100644
--- a/.runtime/portal/RUNBOOK.txt
+++ b/.runtime/portal/RUNBOOK.txt
@@ -47,3 +47,5 @@ Streaming runtime operations:
node scripts/check-tool-runtime.mjs
node scripts/check-agent-code-run-entry.mjs
curl -sk https://mm.tkmind.cn/api/runtime/status # includes toolRuntime.queue
+ node scripts/agent-run-worker.mjs --status
+ node scripts/agent-run-worker.mjs --once
diff --git a/.runtime/portal/scripts/agent-run-worker.mjs b/.runtime/portal/scripts/agent-run-worker.mjs
new file mode 100755
index 0000000..0ca5127
--- /dev/null
+++ b/.runtime/portal/scripts/agent-run-worker.mjs
@@ -0,0 +1,6873 @@
+#!/usr/bin/env node
+import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);
+
+// scripts/agent-run-worker.mjs
+import fs7 from "node:fs";
+import path10 from "node:path";
+import { fileURLToPath as fileURLToPath5 } from "node:url";
+
+// agent-run-gateway.mjs
+import crypto from "node:crypto";
+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;
+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 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)
+ };
+}
+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,
+ 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
+ )
+}) {
+ 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);
+ }
+ 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);
+ 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 });
+ 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 && nextAttempt < retryDelaysMs.length;
+ await markRun(runId, retryable ? "retryable" : "failed", {
+ error_message: message,
+ completed_at: retryable ? null : nowMs()
+ });
+ if (retryable) {
+ setTimeout(() => dispatchRun(runId), retryDelaysMs[nextAttempt - 1]);
+ }
+ }
+ }
+ 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);
+ }
+ return {
+ autoDispatch,
+ maxConcurrentRuns,
+ runTimeoutMs,
+ inFlight: inFlight.size,
+ pendingDispatches: queuedDispatches.length,
+ statusCounts,
+ terminalStatuses: [...TERMINAL_STATUSES]
+ };
+ }
+ async function dispatchQueuedRuns({ limit = maxConcurrentRuns } = {}) {
+ 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,
+ 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,
+ queue: await getQueueStatus()
+ };
+ }
+ return {
+ createRun,
+ getRunForUser,
+ dispatchRun,
+ getQueueStatus,
+ dispatchQueuedRuns
+ };
+}
+
+// db.mjs
+import mysql from "mysql2/promise";
+import path 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 = path.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
+ });
+}
+
+// tkmind-proxy.mjs
+import fs3 from "node:fs";
+import path6 from "node:path";
+import { Readable, Transform as Transform2, Writable } from "node:stream";
+import { pipeline } from "node:stream/promises";
+import { Agent, fetch as undiciFetch } 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 path2 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 path11 = String(pathname ?? "");
+ return path11.startsWith("/mindspace/") || path11.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 path2.join(path2.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");
+ 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 fs from "node:fs";
+import path3 from "node:path";
+import { fileURLToPath as fileURLToPath3 } from "node:url";
+var __dirname2 = path3.dirname(fileURLToPath3(import.meta.url));
+var PUBLISH_SKILL_NAME = "static-page-publish";
+var DOCX_SKILL_NAME = "docx-generate";
+var PUBLISH_ROOT_DIR = "MindSpace";
+var PUBLIC_ZONE_DIR = "public";
+var PUBLISH_SKILL_DIR = path3.join(__dirname2, "skills", PUBLISH_SKILL_NAME);
+var DOCX_SKILL_DIR = path3.join(__dirname2, "skills", DOCX_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 path3.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 path3.join(path3.resolve(canonicalRoot), resolvePublishKey(user));
+ }
+ return resolvePublishDir(h5Root, user);
+}
+function resolveLegacyPublishDir(h5Root, user) {
+ if (!user?.username) return null;
+ return path3.join(h5Root, PUBLISH_ROOT_DIR, resolveUsernameSlug(user));
+}
+function mergePublishTrees(sourceDir, targetDir) {
+ if (!fs.existsSync(sourceDir)) return;
+ for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
+ const from = path3.join(sourceDir, entry.name);
+ const to = path3.join(targetDir, entry.name);
+ if (entry.isDirectory()) {
+ fs.mkdirSync(to, { recursive: true });
+ mergePublishTrees(from, to);
+ } else if (!fs.existsSync(to)) {
+ fs.copyFileSync(from, to);
+ }
+ }
+}
+function migrateUserPublishDir(h5Root, user, legacyUsersRoot = null) {
+ const target = resolvePublishDir(h5Root, user);
+ fs.mkdirSync(target, { recursive: true });
+ const legacyDirs = [
+ resolveLegacyPublishDir(h5Root, user),
+ legacyUsersRoot ? path3.join(legacyUsersRoot, resolveUsernameSlug(user)) : null
+ ].filter(Boolean);
+ for (const legacyDir of legacyDirs) {
+ if (path3.resolve(legacyDir) === path3.resolve(target)) continue;
+ mergePublishTrees(legacyDir, target);
+ }
+ return target;
+}
+function buildPublicUrl(publicBaseUrl, publishKey, filename = "") {
+ const base = `${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}`;
+ if (!filename) return `${base}/`;
+ const clean = filename.replace(/^\/+/, "");
+ return `${base}/${clean.split("/").map(encodeURIComponent).join("/")}`;
+}
+function buildPublicZonePageUrl(publicBaseUrl, publishKey, filename) {
+ const clean = String(filename ?? "").replace(/^\/+/, "");
+ const relative = clean.startsWith(`${PUBLIC_ZONE_DIR}/`) ? clean : `${PUBLIC_ZONE_DIR}/${clean}`;
+ return buildPublicUrl(publicBaseUrl, publishKey, relative);
+}
+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\`\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 \`
\` \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. \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
+7. \u5B8C\u6210\u540E\u6309\u300C\u56DE\u590D\u683C\u5F0F\u300D\u8FD4\u56DE\u53EF\u70B9\u51FB\u94FE\u63A5
+
+## \u4F34\u751F\u4E0B\u8F7D\u6587\u4EF6\uFF08\u5FC5\u987B\uFF09
+
+- \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\u5FC5\u987B\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 \`\u4E0B\u8F7D Word \u6587\u6863\`
+- **\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**
+
+## \u4FE1\u606F\u6D41\u9884\u89C8\u56FE\uFF08\u5FC5\u987B\uFF09
+
+\u6BCF\u4E2A HTML \u9875\u9762\u90FD\u5FC5\u987B\u5728 \`\` \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
+
+
+\`\`\`
+
+| \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 \`\`\uFF0C\`lang="zh-CN"\`
+- \u79FB\u52A8\u7AEF\u53CB\u597D\uFF1A\`\`
+- \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\u8054\u7CFB\u884C**\u5FC5\u987B**\u4F7F\u7528 \`data-mindspace-page-tag="platform-brand"\` \u6807\u8BB0\uFF0C\u4E14\u90AE\u7BB1/\u57DF\u540D\u53EA\u7528 **tkmind.cn**\uFF08\u5982 \`contact@tkmind.cn\`\uFF09\uFF0C**\u7981\u6B62** \`tkmind.ai\`
+
+\`\`\`html
+\u{1F4E7} contact@tkmind.cn
+\`\`\`
+
+- \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
+
+## \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
+`;
+}
+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",
+ "- \u5982\u679C\u7528\u6237\u8981\u6C42 Word / docx \u4E0B\u8F7D\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",
+ "- **\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",
+ "- \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"
+ );
+ 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`",
+ "- **Word \u4E0B\u8F7D\u9875\uFF08\u5FC5\u987B\u4EB2\u81EA\u5B8C\u6210\uFF09**\uFF1A\u82E5\u7528\u6237\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",
+ "- **\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",
+ "- \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",
+ `- \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 = path3.join(publishDir, WORKSPACE_HINTS_FILENAME);
+ const legacyPath = path3.join(publishDir, LEGACY_WORKSPACE_HINTS_FILENAME);
+ const content = renderWorkspaceHints(context);
+ fs.writeFileSync(hintsPath, content, "utf8");
+ if (fs.existsSync(legacyPath)) {
+ fs.unlinkSync(legacyPath);
+ }
+ return hintsPath;
+}
+function ensureDocxSkillInstalled(publishDir) {
+ if (!fs.existsSync(DOCX_SKILL_DIR)) return null;
+ const skillRoot = path3.join(publishDir, ".agents", "skills", DOCX_SKILL_NAME);
+ fs.rmSync(skillRoot, { recursive: true, force: true });
+ fs.mkdirSync(path3.dirname(skillRoot), { recursive: true });
+ fs.cpSync(DOCX_SKILL_DIR, skillRoot, { recursive: true });
+ return path3.join(skillRoot, "SKILL.md");
+}
+function ensurePublishSkillInstalled(publishDir, context) {
+ ensureDocxSkillInstalled(publishDir);
+ const skillRoot = path3.join(publishDir, ".agents", "skills", PUBLISH_SKILL_NAME);
+ fs.mkdirSync(skillRoot, { recursive: true });
+ const skillPath = path3.join(skillRoot, "SKILL.md");
+ const content = renderPublishSkill(context);
+ const existing = fs.existsSync(skillPath) ? fs.readFileSync(skillPath, "utf8") : "";
+ if (existing !== content) {
+ fs.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);
+ fs.mkdirSync(path3.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 fs2 from "node:fs";
+import path4 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 path4.join(workspaceRoot, USER_MEMORY_PROFILE_FILENAME);
+}
+function loadUserMemoryProfile(workspaceRoot) {
+ const profilePath = resolveUserMemoryProfilePath(workspaceRoot);
+ if (!fs2.existsSync(profilePath)) return null;
+ try {
+ const raw = fs2.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()
+ };
+ fs2.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
+ });
+ fs2.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 path5 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 path5.resolve(left) === path5.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 crypto3 from "node:crypto";
+function createImgproxySigner(key, salt) {
+ const keyBuf = Buffer.from(key, "hex");
+ const saltBuf = Buffer.from(salt, "hex");
+ function signPath(path11) {
+ const signaturePayload = Buffer.concat([saltBuf, Buffer.from(path11)]);
+ const signature = crypto3.createHmac("sha256", keyBuf).update(signaturePayload).digest();
+ const signatureB64 = signature.toString("base64url");
+ return `/${signatureB64}${path11}`;
+ }
+ 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 insecureDispatcher = new Agent({
+ 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 undiciFetch(url, {
+ ...init,
+ headers,
+ dispatcher: isHttpsTarget(target) ? insecureDispatcher : 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 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 prev = readNumber(await client.get(ewmaKey).catch(() => null));
+ const next = prev > 0 ? Math.round(prev * 0.8 + sample * 0.2) : sample;
+ const now = String(Date.now());
+ 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);
+ },
+ 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")
+ ]).catch(() => []) : [];
+ 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]),
+ 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 = path6.resolve(process.cwd(), PUBLISH_ROOT_DIR, normalizedOwner);
+ const target = path6.resolve(root2, normalizedRelativePath);
+ if (target !== root2 && !target.startsWith(`${root2}${path6.sep}`)) return false;
+ return fs3.existsSync(target) && fs3.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(path6.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,
+ imgproxySigner = null
+}) {
+ void imgproxySigner;
+ if (!llmProviderService || !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 undiciFetch(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 llmProviderService.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}:
`).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
\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,
+ 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 (!llmProviderService || !sessionId) return null;
+ try {
+ const target = await resolveTarget(sessionId);
+ return await llmProviderService.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 (!llmProviderService || !sessionId) return null;
+ const target = await resolveTarget(sessionId);
+ return llmProviderService.applyLocalFallbackForSession(
+ sessionId,
+ (url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init)
+ );
+ }
+ const visionActiveSessions = /* @__PURE__ */ new Set();
+ async function applyVisionProviderForSession(sessionId) {
+ if (!llmProviderService || !sessionId) return null;
+ const target = await resolveTarget(sessionId);
+ return llmProviderService.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,
+ 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 (llmProviderService && messageHasImages(userMessage) && await llmProviderService.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 && llmProviderService) {
+ const sessionId = sessionMatch?.[1];
+ if (sessionId) {
+ const hasImages = messageHasImages(req.body?.user_message);
+ if (hasImages && await llmProviderService.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)
+ };
+}
+
+// user-auth.mjs
+import crypto4 from "node:crypto";
+import fs6 from "node:fs";
+import path9 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 fs4 from "node:fs";
+import path7 from "node:path";
+var UPLOAD_ZONE_CODES = ["oa", "public"];
+function resolveMindspaceStorageRoot(h5Root, env = process.env) {
+ return path7.resolve(env.MINDSPACE_STORAGE_ROOT ?? path7.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 path7.join(workspaceRoot, categoryCode);
+}
+function resolveZoneFilePath(workspaceRoot, categoryCode, filename) {
+ return path7.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) {
+ fs4.mkdirSync(resolveZoneDir(workspaceRoot, code), { recursive: true });
+ }
+}
+function mirrorAssetToZone({ workspaceRoot, categoryCode, filename, sourcePath }) {
+ if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return null;
+ if (!sourcePath || !fs4.existsSync(sourcePath)) return null;
+ ensureUserZoneDirs(workspaceRoot);
+ const dest = resolveZoneFilePath(workspaceRoot, categoryCode, filename);
+ fs4.mkdirSync(path7.dirname(dest), { recursive: true });
+ fs4.copyFileSync(sourcePath, dest);
+ return dest;
+}
+function isPathInsideUserWorkspace(workspaceRoot, requestedPath) {
+ const base = path7.resolve(workspaceRoot);
+ const resolved = path7.resolve(requestedPath);
+ return resolved === base || resolved.startsWith(`${base}${path7.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
+- \u5982\u679C\u9875\u9762\u91CC\u8FD8\u8981\u63D0\u4F9B Word / docx \u4E0B\u8F7D\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
+- \u9875\u9762\u9700\u8981 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 \`\` \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",
+ "- **Word \u4E0B\u8F7D\u9875**\uFF1A\u82E5\u7528\u6237\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\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 = path7.join(workspaceRoot, WORKSPACE_HINTS_FILENAME2);
+ const legacyPath = path7.join(workspaceRoot, LEGACY_WORKSPACE_HINTS_FILENAME2);
+ const content = renderUserSpaceHints({ ...context, workspaceRoot });
+ fs4.writeFileSync(hintsPath, content, "utf8");
+ if (fs4.existsSync(legacyPath)) {
+ fs4.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 = path7.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 fs5 from "node:fs";
+import path8 from "node:path";
+import { fileURLToPath as fileURLToPath4 } from "node:url";
+var __dirname3 = path8.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,
+ [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 = path8.join(h5Root, "skills");
+ if (!fs5.existsSync(skillsRoot)) return [];
+ const catalog = [];
+ for (const entry of fs5.readdirSync(skillsRoot, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ const skillPath = path8.join(skillsRoot, entry.name, "SKILL.md");
+ if (!fs5.existsSync(skillPath)) continue;
+ const raw = fs5.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) {
+ fs5.mkdirSync(destDir, { recursive: true });
+ for (const entry of fs5.readdirSync(srcDir, { withFileTypes: true })) {
+ const from = path8.join(srcDir, entry.name);
+ const to = path8.join(destDir, entry.name);
+ if (entry.isDirectory()) {
+ copySkillTree(from, to);
+ } else {
+ fs5.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 = path8.join(publishDir, ".agents", "skills");
+ if (fs5.existsSync(agentsSkills)) {
+ for (const entry of fs5.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)) {
+ fs5.rmSync(path8.join(agentsSkills, entry.name), { recursive: true, force: true });
+ }
+ }
+ }
+ for (const item of catalog) {
+ if (!enabled.has(item.name)) continue;
+ const srcDir = path8.join(h5Root, "skills", item.dirName);
+ const destDir = path8.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 (fs5.existsSync(srcDir)) {
+ if (fs5.existsSync(destDir)) fs5.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 && crypto4.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 crypto4.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 = crypto4.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 crypto4.createHash("sha256").update(token).digest("hex");
+}
+function createUserAuth(pool2, options = {}) {
+ const usersRoot = path9.resolve(options.usersRoot ?? "/tmp/tkmind_go_users");
+ const h5Root = path9.resolve(options.h5Root ?? path9.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 (?, ?, ?, ?, ?)`,
+ [crypto4.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) => {
+ fs6.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: `${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(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 && fs6.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 = path9.resolve(user.workspace_root);
+ const target = path9.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, ?, ?)`,
+ [
+ crypto4.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 = crypto4.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 = crypto4.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 = crypto4.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 = path9.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, ?, ?)`,
+ [
+ crypto4.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, ?, ?)`,
+ [
+ crypto4.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 = crypto4.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 = crypto4.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 = crypto4.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ crypto4.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 = crypto4.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) || crypto4.randomBytes(4).toString("hex");
+ let candidate = `wx_${suffix}`.slice(0, 32);
+ if (!isValidUsername(candidate)) {
+ candidate = `wx_${crypto4.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))}${crypto4.randomBytes(2).toString("hex")}`.slice(
+ 0,
+ 32
+ );
+ }
+ return `wx_${crypto4.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 = crypto4.randomBytes(24).toString("base64url");
+ const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword);
+ const userId = crypto4.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ crypto4.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 = path10.join(path10.dirname(fileURLToPath5(import.meta.url)), "..");
+function loadEnvFile(filePath) {
+ if (!fs7.existsSync(filePath)) return;
+ for (const line of fs7.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,
+ pollMs: Number(process.env.MEMIND_AGENT_RUN_WORKER_POLL_MS ?? 1e3),
+ limit: Number(process.env.MEMIND_AGENT_RUN_WORKER_BATCH_SIZE ?? 1)
+ };
+ 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 === "--poll-ms") args2.pollMs = Number(argv[++i] ?? args2.pollMs);
+ else if (item === "--limit") args2.limit = Number(argv[++i] ?? args2.limit);
+ else if (item === "--help" || item === "-h") args2.help = true;
+ }
+ return args2;
+}
+function printHelp() {
+ console.log([
+ "Usage:",
+ " node scripts/agent-run-worker.mjs [--once] [--status] [--poll-ms 1000] [--limit 1]",
+ "",
+ "Notes:",
+ " - Processes existing h5_agent_runs queued/retryable rows through the Tool Gateway queue.",
+ " - Does not create agent runs by itself.",
+ " - 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 || path10.join(root, ".env"));
+loadEnvFile(path10.join(root, ".env.local"));
+var args = parseArgs(process.argv.slice(2));
+if (args.help) {
+ printHelp();
+ process.exit(0);
+}
+var pool = createDbPool();
+var userAuth = createUserAuth(pool, {
+ usersRoot: process.env.H5_USERS_ROOT ?? path10.join(root, "users"),
+ h5Root: root
+});
+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,
+ 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() {
+ const result = await gateway.dispatchQueuedRuns({ limit: args.limit });
+ console.log(JSON.stringify({
+ ok: true,
+ mode: args.status ? "status" : "dispatch",
+ dispatched: args.status ? 0 : result.dispatched,
+ queue: result.queue
+ }));
+}
+try {
+ if (args.status) {
+ console.log(JSON.stringify({ ok: true, queue: await gateway.getQueueStatus() }, null, 2));
+ } else if (args.once) {
+ 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)));
+ }
+ }
+} finally {
+ await pool.end().catch(() => {
+ });
+}
diff --git a/.runtime/portal/server.mjs b/.runtime/portal/server.mjs
index 7afe562..0610c15 100644
--- a/.runtime/portal/server.mjs
+++ b/.runtime/portal/server.mjs
@@ -6534,6 +6534,11 @@ function timeoutError(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";
@@ -6589,7 +6594,7 @@ function createAgentRunGateway({
userAuth: userAuth2,
tkmindProxy: tkmindProxy2,
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
- autoDispatch = true,
+ autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true),
maxConcurrentRuns = positiveInteger(
process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY,
DEFAULT_MAX_CONCURRENT_RUNS
@@ -6810,6 +6815,7 @@ function createAgentRunGateway({
statusCounts[row.status] = Number(row.count ?? 0);
}
return {
+ autoDispatch,
maxConcurrentRuns,
runTimeoutMs,
inFlight: inFlight.size,
@@ -6818,22 +6824,51 @@ function createAgentRunGateway({
terminalStatuses: [...TERMINAL_STATUSES]
};
}
+ async function dispatchQueuedRuns({ limit = maxConcurrentRuns } = {}) {
+ 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,
+ 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,
+ queue: await getQueueStatus()
+ };
+ }
return {
createRun,
getRunForUser,
dispatchRun,
- getQueueStatus
+ getQueueStatus,
+ dispatchQueuedRuns
};
}
// agent-run-routes.mjs
-function envFlag(value) {
+function envFlag2(value) {
return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
}
function createPostAgentRunsHandler({
userAuth: userAuth2,
agentRunGateway: agentRunGateway2,
- codeRunsEnabled = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED)
+ codeRunsEnabled = envFlag2(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED)
}) {
return async function postAgentRuns(request, response) {
try {
@@ -35041,6 +35076,9 @@ async function bootstrapUserAuth() {
pool: pool2,
userAuth,
tkmindProxy,
+ autoDispatch: ["1", "true", "yes", "on"].includes(
+ String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? "1").trim().toLowerCase()
+ ),
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1e3)
});
diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs
index f716115..8299dda 100644
--- a/agent-run-gateway.mjs
+++ b/agent-run-gateway.mjs
@@ -35,6 +35,12 @@ function timeoutError(ms) {
return err;
}
+function envFlag(value, fallback = false) {
+ const raw = String(value ?? '').trim().toLowerCase();
+ if (!raw) return fallback;
+ return ['1', 'true', 'yes', 'on'].includes(raw);
+}
+
export function normalizeAgentRunToolMode(value) {
const normalized = String(value ?? 'chat').trim().toLowerCase();
if (!normalized || normalized === 'chat') return 'chat';
@@ -101,7 +107,7 @@ export function createAgentRunGateway({
userAuth,
tkmindProxy,
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
- autoDispatch = true,
+ autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true),
maxConcurrentRuns = positiveInteger(
process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY,
DEFAULT_MAX_CONCURRENT_RUNS,
@@ -340,6 +346,7 @@ export function createAgentRunGateway({
statusCounts[row.status] = Number(row.count ?? 0);
}
return {
+ autoDispatch,
maxConcurrentRuns,
runTimeoutMs,
inFlight: inFlight.size,
@@ -349,10 +356,40 @@ export function createAgentRunGateway({
};
}
+ async function dispatchQueuedRuns({ limit = maxConcurrentRuns } = {}) {
+ 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,
+ queue: await getQueueStatus(),
+ };
+ }
+ const take = Math.min(dispatchLimit, available);
+ const [rows] = await pool.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,
+ queue: await getQueueStatus(),
+ };
+ }
+
return {
createRun,
getRunForUser,
dispatchRun,
getQueueStatus,
+ dispatchQueuedRuns,
};
}
diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs
index 40a46a7..51a866c 100644
--- a/agent-run-gateway.test.mjs
+++ b/agent-run-gateway.test.mjs
@@ -30,6 +30,14 @@ function createFakePool() {
}
return [[...counts].map(([status, count]) => ({ status, count }))];
}
+ if (sql.includes('SELECT id') && sql.includes("status IN ('queued', 'retryable')")) {
+ const limit = Number(params[0] ?? 1);
+ return [[...runs.values()]
+ .filter((row) => ['queued', 'retryable'].includes(row.status))
+ .sort((a, b) => Number(a.updated_at) - Number(b.updated_at))
+ .slice(0, limit)
+ .map((row) => ({ id: row.id }))];
+ }
if (sql.includes('INSERT INTO h5_agent_runs')) {
const [
id,
@@ -338,3 +346,77 @@ test('agent run queue status reports active database and local queue state', asy
assert.equal(status.pendingDispatches, 0);
assert.equal(status.statusCounts.queued, 1);
});
+
+test('external worker dispatches queued runs through the same queue controls', async () => {
+ const pool = createFakePool();
+ const submitted = [];
+ const gateway = createAgentRunGateway({
+ pool,
+ userAuth: {},
+ tkmindProxy: {
+ async startSessionForUser() {
+ return { id: 'session-worker' };
+ },
+ async submitSessionReplyForUser(_userId, _sessionId, requestId) {
+ submitted.push(requestId);
+ },
+ },
+ autoDispatch: false,
+ retryDelaysMs: [],
+ maxConcurrentRuns: 1,
+ });
+
+ const run = await gateway.createRun('user-1', {
+ requestId: 'req-worker',
+ userMessage: { role: 'user', content: [] },
+ });
+ assert.equal(pool.runs.get(run.id).status, 'queued');
+
+ const result = await gateway.dispatchQueuedRuns({ limit: 10 });
+ assert.equal(result.dispatched, 1);
+ await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
+ assert.deepEqual(submitted, ['req-worker']);
+});
+
+test('external worker does not dispatch more runs when local queue is full', async () => {
+ const pool = createFakePool();
+ const release = [];
+ const gateway = createAgentRunGateway({
+ pool,
+ userAuth: {},
+ tkmindProxy: {
+ async startSessionForUser() {
+ return { id: `session-${release.length + 1}` };
+ },
+ async submitSessionReplyForUser() {
+ await new Promise((resolve) => release.push(resolve));
+ },
+ },
+ autoDispatch: false,
+ retryDelaysMs: [],
+ maxConcurrentRuns: 1,
+ });
+
+ const run1 = await gateway.createRun('user-1', {
+ requestId: 'req-full-1',
+ userMessage: { role: 'user', content: [] },
+ });
+ const run2 = await gateway.createRun('user-1', {
+ requestId: 'req-full-2',
+ userMessage: { role: 'user', content: [] },
+ });
+
+ try {
+ const first = await gateway.dispatchQueuedRuns({ limit: 10 });
+ assert.equal(first.dispatched, 1);
+ await waitFor(() => pool.runs.get(run1.id)?.status === 'running');
+ assert.equal((await gateway.getQueueStatus()).inFlight, 1);
+ const second = await gateway.dispatchQueuedRuns({ limit: 10 });
+ assert.equal(second.dispatched, 0);
+ assert.equal(pool.runs.get(run2.id).status, 'queued');
+ } finally {
+ while (release.length > 0) release.shift()();
+ }
+ await waitFor(() => pool.runs.get(run1.id)?.status === 'succeeded');
+ assert.equal(pool.runs.get(run2.id).status, 'queued');
+});
diff --git a/scripts/agent-run-worker.mjs b/scripts/agent-run-worker.mjs
new file mode 100644
index 0000000..8391f1e
--- /dev/null
+++ b/scripts/agent-run-worker.mjs
@@ -0,0 +1,121 @@
+#!/usr/bin/env node
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createAgentRunGateway } from '../agent-run-gateway.mjs';
+import { createDbPool } from '../db.mjs';
+import { createTkmindProxy } from '../tkmind-proxy.mjs';
+import { createUserAuth } from '../user-auth.mjs';
+
+const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+function loadEnvFile(filePath) {
+ if (!fs.existsSync(filePath)) return;
+ for (const line of fs.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 args = {
+ once: false,
+ status: false,
+ pollMs: Number(process.env.MEMIND_AGENT_RUN_WORKER_POLL_MS ?? 1000),
+ limit: Number(process.env.MEMIND_AGENT_RUN_WORKER_BATCH_SIZE ?? 1),
+ };
+ for (let i = 0; i < argv.length; i += 1) {
+ const item = argv[i];
+ if (item === '--once') args.once = true;
+ else if (item === '--status') args.status = true;
+ else if (item === '--poll-ms') args.pollMs = Number(argv[++i] ?? args.pollMs);
+ else if (item === '--limit') args.limit = Number(argv[++i] ?? args.limit);
+ else if (item === '--help' || item === '-h') args.help = true;
+ }
+ return args;
+}
+
+function printHelp() {
+ console.log([
+ 'Usage:',
+ ' node scripts/agent-run-worker.mjs [--once] [--status] [--poll-ms 1000] [--limit 1]',
+ '',
+ 'Notes:',
+ ' - Processes existing h5_agent_runs queued/retryable rows through the Tool Gateway queue.',
+ ' - Does not create agent runs by itself.',
+ ' - 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 || path.join(root, '.env'));
+loadEnvFile(path.join(root, '.env.local'));
+
+const args = parseArgs(process.argv.slice(2));
+if (args.help) {
+ printHelp();
+ process.exit(0);
+}
+
+const pool = createDbPool();
+const userAuth = createUserAuth(pool, {
+ usersRoot: process.env.H5_USERS_ROOT ?? path.join(root, 'users'),
+ h5Root: root,
+});
+const apiTargets = parseTargets();
+const tkmindProxy = createTkmindProxy({
+ apiTarget: apiTargets[0],
+ apiTargets,
+ apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
+ userAuth,
+});
+const gateway = createAgentRunGateway({
+ pool,
+ userAuth,
+ tkmindProxy,
+ autoDispatch: false,
+ maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
+ runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000),
+});
+
+let stopping = false;
+process.on('SIGINT', () => { stopping = true; });
+process.on('SIGTERM', () => { stopping = true; });
+
+async function runOnce() {
+ const result = await gateway.dispatchQueuedRuns({ limit: args.limit });
+ console.log(JSON.stringify({
+ ok: true,
+ mode: args.status ? 'status' : 'dispatch',
+ dispatched: args.status ? 0 : result.dispatched,
+ queue: result.queue,
+ }));
+}
+
+try {
+ if (args.status) {
+ console.log(JSON.stringify({ ok: true, queue: await gateway.getQueueStatus() }, null, 2));
+ } else if (args.once) {
+ 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)));
+ }
+ }
+} finally {
+ await pool.end().catch(() => {});
+}
diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs
index ecc5cbb..4e8f50a 100755
--- a/scripts/build-portal-runtime.mjs
+++ b/scripts/build-portal-runtime.mjs
@@ -119,6 +119,26 @@ async function bundleSandboxMcp() {
await run(esbuildBin, args);
}
+async function bundleAgentRunWorker() {
+ if (!(await exists(esbuildBin))) {
+ throw new Error(`未找到 esbuild: ${esbuildBin}`);
+ }
+ console.log('==> 打包 agent run worker 为单文件 runtime');
+ const args = [
+ 'scripts/agent-run-worker.mjs',
+ '--bundle',
+ '--platform=node',
+ '--format=esm',
+ `--target=${runtimeNodeTarget}`,
+ '--outfile=.runtime/portal/scripts/agent-run-worker.mjs',
+ '--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
+ ];
+ for (const pkg of externalPackages) {
+ args.push(`--external:${pkg}`);
+ }
+ await run(esbuildBin, args);
+}
+
async function copyRuntimeAssets() {
console.log('==> 拷贝运行时静态资源');
await remove(runtimeRoot);
@@ -347,6 +367,8 @@ async function writeMetadata() {
' node scripts/check-tool-runtime.mjs',
' node scripts/check-agent-code-run-entry.mjs',
' curl -sk https://mm.tkmind.cn/api/runtime/status # includes toolRuntime.queue',
+ ' node scripts/agent-run-worker.mjs --status',
+ ' node scripts/agent-run-worker.mjs --once',
'',
].join('\n'),
);
@@ -357,6 +379,7 @@ async function main() {
await copyRuntimeAssets();
await bundleServer();
await bundleSandboxMcp();
+ await bundleAgentRunWorker();
if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) {
await copyNodeModules();
}
@@ -368,6 +391,7 @@ async function main() {
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-drain.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-metrics.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-slo-report.mjs'), 0o755);
+ await fs.chmod(path.join(runtimeRoot, 'scripts', 'agent-run-worker.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-runtime-metrics-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-tool-runtime.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'memind-portal-tunnel.sh'), 0o755);
diff --git a/server.mjs b/server.mjs
index 85b8d5b..3ddfae6 100644
--- a/server.mjs
+++ b/server.mjs
@@ -572,6 +572,9 @@ async function bootstrapUserAuth() {
pool,
userAuth,
tkmindProxy,
+ autoDispatch: ['1', 'true', 'yes', 'on'].includes(
+ String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(),
+ ),
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000),
});