import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { isPageDataIntent } from './chat-skills.mjs'; import { detectPageDataDatasetUsageFromHtml, htmlUsesForbiddenLegacyPageDataApi, htmlUsesPageDataApi, inferPageDataBindAccessMode, } from './page-data-html-detect.mjs'; import { createPageService } from './mindspace-pages.mjs'; import { ensurePageDataHtmlPagesBound } from './page-data-workspace-ensure.mjs'; import { assessPageDataHtmlBinding, assessWorkspacePageDataReadiness, normalizePageDataApiBase, verifyPageDataDeliveryArtifacts, } from './page-data-delivery-assess.mjs'; import { listPageAccessPolicies } from './page-data-policy-store.mjs'; import { buildPublicUrl, resolvePageDataDeliveryBaseUrl } from './user-publish.mjs'; import { detectProhibitedBrowserStorage } from './mindspace-browser-storage-policy.mjs'; export { inferPageDataBindAccessMode }; const PUBLICATION_ROUTE_LINK_PATTERN = /https?:\/\/[^\s<>"')\]]+\/u\/([0-9a-f-]{36}|[a-z0-9._-]+)\/pages\/([^\s<>"')\]]+)/gi; const PAGE_DATA_CLIENT_SCRIPT_PATTERN = /\/assets\/page-data-client\.js/i; const LEGACY_PAGE_DATA_OWNER_API_PATTERN = /['"`]\/api\/page-data(?:\/|['"`])/i; const LOCAL_STORAGE_FALLBACK_HINT_PATTERN = /fallback\s*到\s*localStorage|localStorage\s*fallback/i; const repairAttemptsBySession = new Map(); /** * Explicit user choice wins over fallible text-intent classification. The UI * records this on the original user message before any agent execution starts. */ function isPgRequiredByMessage(messages = []) { return messages.some( (message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true, ); } function envFlag(value, fallback = false) { const raw = String(value ?? '').trim().toLowerCase(); if (!raw) return fallback; return ['1', 'true', 'yes', 'on'].includes(raw); } function readPublicHtmlFiles(publishDir) { const publicDir = path.join(path.resolve(String(publishDir ?? '')), 'public'); if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) return []; return fs .readdirSync(publicDir) .filter((name) => name.toLowerCase().endsWith('.html')) .map((name) => { const relativePath = `public/${name}`; const absolutePath = path.join(publicDir, name); const content = fs.readFileSync(absolutePath, 'utf8'); return { relativePath, absolutePath, content }; }); } export function collectPageDataDeliveryArtifacts( publishDir, { publicBaseUrl = resolvePageDataDeliveryBaseUrl() } = {}, ) { const ownerKey = path.basename(path.resolve(String(publishDir ?? ''))); if (!ownerKey) return []; return collectPageDataPublicHtmlFiles(publishDir) .map((file) => ({ localPath: file.absolutePath, relativePath: file.relativePath, isAdmin: /-admin\.html$/i.test(file.relativePath), url: buildPublicUrl(publicBaseUrl, ownerKey, file.relativePath), })); } function pickPublicationRouteReplacement(slug, artifacts, { adminUsed, surveyIndex }) { const normalizedSlug = String(slug ?? '').toLowerCase(); const adminArtifact = artifacts.find((artifact) => artifact.isAdmin); const surveyArtifacts = artifacts.filter((artifact) => !artifact.isAdmin); if (adminArtifact && /admin/.test(normalizedSlug) && !adminUsed.value) { adminUsed.value = true; return adminArtifact.url; } if (surveyArtifacts[surveyIndex.value]) { surveyIndex.value += 1; return surveyArtifacts[surveyIndex.value - 1].url; } if (adminArtifact && !adminUsed.value) { adminUsed.value = true; return adminArtifact.url; } return artifacts[0]?.url ?? null; } /** 将 Agent 误发的 /u/用户名/pages/... 链接改写为 MindSpace 工作区 URL。 */ export function rewritePageDataDeliveryLinks(text, artifacts = []) { const value = String(text ?? ''); if (!value || !Array.isArray(artifacts) || artifacts.length === 0) return value; const adminUsed = { value: false }; const surveyIndex = { value: 0 }; return value.replace(PUBLICATION_ROUTE_LINK_PATTERN, (match, _owner, slug) => { const replacement = pickPublicationRouteReplacement(slug, artifacts, { adminUsed, surveyIndex }); return replacement ?? match; }); } export function buildPageDataDeliveryArtifactsFromBindResult(autoBind, publishDir, options = {}) { const boundUrls = new Map( (autoBind?.bound ?? []) .filter((item) => item?.relativePath && item?.workspaceUrl) .map((item) => [item.relativePath, item.workspaceUrl]), ); return collectPageDataDeliveryArtifacts(publishDir, options).map((artifact) => ({ ...artifact, url: boundUrls.get(artifact.relativePath) ?? artifact.url, })); } export function evaluatePageDataHtmlContent(html, { relativePath = '' } = {}) { const content = String(html ?? ''); const usage = detectPageDataDatasetUsageFromHtml(content); const prohibitedBrowserStorage = detectProhibitedBrowserStorage(content, { relativePath }); const usesPageDataApi = usage.size > 0 || PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) || /\bMindSpacePageData\b/.test(content) || htmlUsesForbiddenLegacyPageDataApi(content) || LEGACY_PAGE_DATA_OWNER_API_PATTERN.test(content) || prohibitedBrowserStorage.length > 0 || LOCAL_STORAGE_FALLBACK_HINT_PATTERN.test(content); if (!usesPageDataApi) { return { usesPageDataApi: false, issues: [] }; } const issues = []; if (htmlUsesForbiddenLegacyPageDataApi(content)) { issues.push('forbidden_legacy_page_data_api'); } if (!PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content)) { issues.push('missing_page_data_client_script'); } if (LEGACY_PAGE_DATA_OWNER_API_PATTERN.test(content)) { issues.push('forbidden_legacy_owner_api'); } if (prohibitedBrowserStorage.includes('localStorage')) { issues.push('forbidden_local_storage'); } if (prohibitedBrowserStorage.some((api) => api !== 'localStorage')) { issues.push('forbidden_browser_storage'); } if (LOCAL_STORAGE_FALLBACK_HINT_PATTERN.test(content)) { issues.push('forbidden_local_storage_fallback'); } if (usage.size > 0 && !/\bMindSpacePageData\b/.test(content)) { issues.push('missing_page_data_client_api'); } if (relativePath && usage.size > 0) { const hasInsert = [...usage.values()].some((item) => item.insert); const hasRead = [...usage.values()].some((item) => item.read); const looksAdmin = /-admin\.html$/i.test(relativePath); if (looksAdmin && hasInsert && !hasRead) { issues.push('admin_page_should_not_insert_only'); } if (!looksAdmin && hasRead && !hasInsert) { issues.push('survey_page_should_not_be_read_only'); } } return { usesPageDataApi: true, issues, usage }; } export function collectPageDataPublicHtmlFiles(publishDir) { return readPublicHtmlFiles(publishDir) .map((file) => ({ ...file, evaluation: evaluatePageDataHtmlContent(file.content, { relativePath: file.relativePath }), })) .filter((file) => file.evaluation.usesPageDataApi); } function isPageDataHtmlWorkspaceReady({ publishDir, relativePath, html }) { const usage = detectPageDataDatasetUsageFromHtml(html); if (!usage.size) return true; return Boolean(findWorkspacePolicyForPageDataHtml({ publishDir, relativePath, html })); } function findWorkspacePolicyForPageDataHtml({ publishDir, relativePath, html }) { const usage = detectPageDataDatasetUsageFromHtml(html); const expectedAccessMode = inferPageDataBindAccessMode(relativePath, html); for (const policy of listPageAccessPolicies(publishDir)) { if (String(policy?.accessMode ?? '') !== expectedAccessMode) continue; if ([...usage.entries()].every(([name, perms]) => { const granted = policy?.datasets?.[name]; return Boolean(granted && (!perms.read || granted.read) && (!perms.insert || granted.insert)); })) return policy; } return null; } async function collectUnboundPageDataFiles({ relevantFiles, publishDir, pool = null, userId = null, findPageByRelativePath = null, }) { const unboundFiles = []; for (const file of relevantFiles) { if (file.evaluation.usage?.size === 0) continue; if (pool && userId && typeof findPageByRelativePath === 'function') { const assessment = await assessPageDataHtmlBinding({ pool, userId, publishDir, relativePath: file.relativePath, html: file.content, findPageByRelativePath, }); if (!assessment.bound) { unboundFiles.push({ ...file, bindReasons: assessment.reasons, }); } continue; } const workspace = await assessWorkspacePageDataReadiness({ userId, publishDir, relativePath: file.relativePath, html: file.content, }); if (!workspace.ready) { unboundFiles.push(file); } } return unboundFiles; } export function usedPageDataCollectSkill(messages = []) { return messages.some((message) => message?.content?.some((item) => { if (item?.type !== 'toolRequest') return false; const toolCall = item.toolCall?.value; const name = String(toolCall?.name ?? '').trim(); const args = toolCall?.arguments ?? {}; const skillName = String(args.name ?? '').trim(); return name === 'load_skill' && skillName === 'page-data-collect'; }), ); } export function usedPageDataBindTool(messages = []) { return messages.some((message) => message?.content?.some((item) => { if (item?.type !== 'toolRequest') return false; const name = String(item.toolCall?.value?.name ?? '').trim(); return name === 'private_data_bind_workspace_page'; }), ); } export function extractRecentPageDataHtmlWrites(messages = [], { sinceMs = 0 } = {}) { const targets = new Set(); for (const message of Array.isArray(messages) ? messages : []) { const createdAt = Number(message?.created ?? message?.createdAt ?? 0); if (sinceMs > 0 && createdAt > 0 && createdAt < sinceMs) continue; for (const item of message?.content ?? []) { if (item?.type !== 'toolRequest') continue; const toolCall = item.toolCall?.value; const name = String(toolCall?.name ?? '').trim(); const normalizedName = name.split('__').at(-1); if (!['write_file', 'edit_file', 'write', 'edit'].includes(normalizedName)) continue; const args = toolCall?.arguments ?? {}; const candidate = String(args.path ?? args.file_path ?? '').trim().replace(/\\/g, '/'); if (!candidate.toLowerCase().endsWith('.html')) continue; const normalized = candidate.startsWith('public/') ? candidate : path.posix.basename(candidate); if (normalized.startsWith('public/')) targets.add(normalized); } } return [...targets]; } export function extractRecentPageDataBindTargets(messages = [], { sinceMs = 0 } = {}) { const targets = new Set(); for (const message of Array.isArray(messages) ? messages : []) { const createdAt = Number(message?.created ?? message?.createdAt ?? 0); if (sinceMs > 0 && createdAt > 0 && createdAt < sinceMs) continue; for (const item of message?.content ?? []) { if (item?.type !== 'toolRequest') continue; const toolCall = item.toolCall?.value; const name = String(toolCall?.name ?? '').trim().split('__').at(-1); if (name !== 'private_data_bind_workspace_page') continue; const candidate = String(toolCall?.arguments?.relativePath ?? '') .trim() .replace(/\\/g, '/'); if (!candidate.toLowerCase().endsWith('.html')) continue; targets.add(candidate.startsWith('public/') ? candidate : `public/${path.posix.basename(candidate)}`); } } return [...targets]; } function isStructuralPageDataHtmlFile(file) { return Boolean(file?.evaluation?.usage?.size > 0 || file?.evaluation?.usesPageDataApi); } function resolvePageDataGuardAgentText({ agentText = '', messages = [] } = {}) { const direct = String(agentText ?? '').trim(); if (isPageDataIntent(direct)) return direct; for (let i = messages.length - 1; i >= 0; i -= 1) { const message = messages[i]; if (message?.role !== 'user') continue; const text = Array.isArray(message?.content) ? message.content .filter((item) => item?.type === 'text') .map((item) => String(item.text ?? '').trim()) .filter(Boolean) .join('\n') : String(message?.content ?? '').trim(); if (isPageDataIntent(text)) return text; } return direct; } export function evaluatePageDataFinishGuard({ publishDir, agentText = '', messages = [], requestStartedAt = 0, } = {}) { const resolvedAgentText = resolvePageDataGuardAgentText({ agentText, messages }); const pgRequired = isPgRequiredByMessage(messages); const pageDataIntent = pgRequired || isPageDataIntent(resolvedAgentText); const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir); const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }); const recentBinds = extractRecentPageDataBindTargets(messages, { sinceMs: requestStartedAt }); const relevantPaths = new Set([...recentWrites, ...recentBinds]); // A reused session/workspace can contain many historical Page Data pages. // Only files touched or explicitly bound in this request belong to this // delivery. Scanning the whole workspace lets one stale localStorage page // incorrectly block every later, correctly bound survey. const relevantFiles = pageDataFiles.filter((file) => relevantPaths.has(file.relativePath)); const structuralFiles = relevantFiles.filter(isStructuralPageDataHtmlFile); const htmlIssues = relevantFiles.flatMap((file) => file.evaluation.issues.map((issue) => ({ issue, relativePath: file.relativePath, })), ); const unboundFiles = relevantFiles.filter( (file) => file.evaluation.usage?.size > 0 && !isPageDataHtmlWorkspaceReady({ publishDir, relativePath: file.relativePath, html: file.content, }), ); const needsRepair = (pageDataIntent || structuralFiles.length > 0) && (htmlIssues.length > 0 || unboundFiles.length > 0 || (relevantFiles.length === 0 && extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && (usedPageDataCollectSkill(messages) || pgRequired))); return { pageDataIntent, pgRequired, structuralPageData: structuralFiles.length > 0, relevantFiles, htmlIssues, unboundFiles, needsRepair, usedSkill: usedPageDataCollectSkill(messages), usedBindTool: usedPageDataBindTool(messages), }; } export async function evaluatePageDataFinishGuardAsync({ publishDir, agentText = '', messages = [], requestStartedAt = 0, pool = null, userId = null, findPageByRelativePath = null, } = {}) { const base = evaluatePageDataFinishGuard({ publishDir, agentText, messages, requestStartedAt, }); const unboundFiles = await collectUnboundPageDataFiles({ relevantFiles: base.relevantFiles, publishDir, pool, userId, findPageByRelativePath, }); const needsRepair = (base.structuralPageData || base.pgRequired) && (base.htmlIssues.length > 0 || unboundFiles.length > 0 || (base.pageDataIntent && base.relevantFiles.length === 0 && extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && (usedPageDataCollectSkill(messages) || base.pgRequired))); return { ...base, unboundFiles, needsRepair, }; } export function buildPageDataCollectFailureText() { return [ '这次问卷/数据收集页面没有完成 Page Data API 绑定,所以我先不发链接。', '请直接重发一次完整需求(例如:调查问卷 + 后台查看),我会按 page-data-collect 技能:', '建表 → 注册 dataset → 写含 page-data-client.js 的 HTML → private_data_bind_workspace_page 发布。', '数据必须走平台 API(MindSpacePageData.createClient + /api/public/pages/...)写入用户私有数据空间,禁止 /api/page-data/ 旁路、localStorage、sessionStorage、IndexedDB 或自建后端。', ].join(''); } export function buildPageDataCollectRepairPrompt({ htmlIssues = [], unboundFiles = [], } = {}) { const lines = [ '【系统交付门禁】本轮用户已要求使用专属 PostgreSQL 数据空间,但 Page Data 页面交付尚未验证完成。请立即按 page-data-collect 技能修复:', '1. load_skill → page-data-collect', '2. 确保 public/*.html 引入 /assets/page-data-client.js,且 JS 使用 MindSpacePageData.createClient({ apiBase: "/api" })', '3. 禁止 /api/page-data/ 旁路、localStorage 或自建后端', '4. 对每个 public/*.html 调用 private_data_bind_workspace_page(问卷页 public insert,后台页 password read,口令默认 88888888)', '5. 向用户交付的链接必须使用 Portal(本地 http://127.0.0.1:8081),不要用 Vite 5173', ]; if (htmlIssues.length) { lines.push('', 'HTML 问题:'); for (const item of htmlIssues) { lines.push(`- ${item.relativePath}: ${item.issue}`); } } if (unboundFiles.length) { lines.push('', '尚未 bind 的 Page Data 页面:'); for (const file of unboundFiles) { const reasons = Array.isArray(file.bindReasons) && file.bindReasons.length ? ` (${file.bindReasons.join(', ')})` : ''; lines.push(`- ${file.relativePath}${reasons}`); } } lines.push('', '修复完成前不要告诉用户“已发布/已可提交”。'); return lines.join('\n'); } export function shouldRetryPageDataCollectReply({ reply, intent, publishDir, confirmedArtifacts = [], requestStartedAt = 0, }) { const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim(); if (!isPageDataIntent(agentText)) return false; const evaluation = evaluatePageDataFinishGuard({ publishDir, agentText, messages: reply?.messages ?? [], requestStartedAt, }); if (!evaluation.pageDataIntent) return false; if (evaluation.htmlIssues.length > 0) return true; if (evaluation.unboundFiles.length > 0) return true; const wrotePageDataHtml = evaluation.relevantFiles.length > 0; const wroteAnyHtml = confirmedArtifacts.length > 0 || extractRecentPageDataHtmlWrites(reply?.messages ?? [], { sinceMs: requestStartedAt }).length > 0; if (wroteAnyHtml && !usedPageDataCollectSkill(reply?.messages ?? []) && wrotePageDataHtml) { return true; } const replyClaimsDone = /(?:问卷|报名|后台|数据).*(?:已创建|已生成|已发布|可以提交|完成)/iu.test(String(reply?.text ?? '')); if (replyClaimsDone && evaluation.unboundFiles.length > 0) return true; return false; } export function resolvePageDataCollectOutcome({ reply, intent, publishDir, requestStartedAt = 0, }) { const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim(); const evaluation = evaluatePageDataFinishGuard({ publishDir, agentText, messages: reply?.messages ?? [], requestStartedAt, }); if (!evaluation.structuralPageData && !evaluation.pageDataIntent) { return { action: 'skip' }; } if (evaluation.htmlIssues.length > 0) { return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation }; } if (evaluation.unboundFiles.length > 0) { return { action: 'retry', reason: 'missing_bind', evaluation }; } if (shouldRetryPageDataCollectReply({ reply, intent, publishDir, requestStartedAt })) { return { action: 'retry', reason: 'incomplete_delivery', evaluation }; } return { action: 'send', evaluation }; } export async function resolvePageDataCollectOutcomeAsync({ reply, intent, publishDir, requestStartedAt = 0, pool = null, userId = null, findPageByRelativePath = null, apiBase = null, fetchImpl = fetch, } = {}) { const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim(); const evaluation = await evaluatePageDataFinishGuardAsync({ publishDir, agentText, messages: reply?.messages ?? [], requestStartedAt, pool, userId, findPageByRelativePath, }); if (!evaluation.structuralPageData && !evaluation.pageDataIntent) { return { action: 'skip' }; } if (evaluation.htmlIssues.length > 0) { return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation }; } const deliveryArtifacts = evaluation.relevantFiles.length > 0 ? collectPageDataDeliveryArtifacts(publishDir).filter((artifact) => evaluation.relevantFiles.some((file) => file.relativePath === artifact.relativePath), ) : []; if (pool && userId && apiBase && deliveryArtifacts.length > 0) { const deliveryFailures = await verifyPageDataDeliveryArtifacts({ artifacts: deliveryArtifacts, publishDir, apiBase, pool, userId, findPageByRelativePath, fetchImpl, }); if (deliveryFailures.length === 0) { const reassessment = await evaluatePageDataFinishGuardAsync({ publishDir, agentText, messages: reply?.messages ?? [], requestStartedAt, pool, userId, findPageByRelativePath, }); if (reassessment.unboundFiles.length === 0 && reassessment.htmlIssues.length === 0) { return { action: 'send', reason: 'verified_by_live_api', evaluation: reassessment }; } } else if (evaluation.unboundFiles.length > 0) { return { action: 'retry', reason: 'missing_bind', evaluation, deliveryFailures, }; } else { return { action: 'retry', reason: 'delivery_smoke_failed', evaluation, deliveryFailures, }; } } if (evaluation.unboundFiles.length > 0) { return { action: 'retry', reason: 'missing_bind', evaluation }; } if (shouldRetryPageDataCollectReply({ reply, intent, publishDir, requestStartedAt })) { return { action: 'retry', reason: 'incomplete_delivery', evaluation }; } return { action: 'send', evaluation }; } export function isPageDataFinishGuardEnabled(env = process.env) { return envFlag(env.MEMIND_PAGE_DATA_FINISH_GUARD, true); } export function resetPageDataFinishGuardAttempts(sessionId = null) { if (sessionId) repairAttemptsBySession.delete(String(sessionId)); else repairAttemptsBySession.clear(); } export async function maybeAutoBindPageDataHtmlPages({ pool, userId, publishDir, h5Root, storageRoot, onlyRelativePaths = null, findPageByRelativePath = null, } = {}) { return ensurePageDataHtmlPagesBound({ pool, h5Root, storageRoot, userId, workspaceRoot: publishDir, findPageByRelativePath, onlyRelativePaths, }); } export async function ensurePageDataDeliveryReady({ publishDir, userId, pool, h5Root, storageRoot, apiBase, artifacts = [], fetchImpl = fetch, }) { if (!pool || !userId) { return { ok: false, failures: [{ stage: 'binding', reason: 'database_unconfigured' }] }; } const pageService = createPageService(pool, { h5Root, storageRoot }); const failures = await verifyPageDataDeliveryArtifacts({ artifacts, publishDir, apiBase: normalizePageDataApiBase(apiBase), pool, userId, findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService), fetchImpl, }); return { ok: failures.length === 0, failures }; } export async function maybeRepairPageDataAfterFinish({ sessionId, userId, publishDir, messages, pool, h5Root, storageRoot, tkmindProxy = null, maxAttempts = 1, env = process.env, logger = console, userText = '', } = {}) { const recentUserText = String(userText ?? '').trim(); const pageService = pool ? createPageService(pool, { h5Root, storageRoot }) : null; const evaluation = await evaluatePageDataFinishGuardAsync({ publishDir, agentText: resolvePageDataGuardAgentText({ agentText: recentUserText, messages }), messages, pool, userId, findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null, }); if (!evaluation.structuralPageData && evaluation.relevantFiles.length === 0) { resetPageDataFinishGuardAttempts(sessionId); return { repaired: false, skipped: 'not_page_data', ...evaluation }; } const autoBind = await maybeAutoBindPageDataHtmlPages({ pool, userId, publishDir, h5Root, storageRoot, onlyRelativePaths: evaluation.relevantFiles.length ? evaluation.relevantFiles.map((file) => file.relativePath) : null, findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null, }); const afterBind = await evaluatePageDataFinishGuardAsync({ publishDir, agentText: resolvePageDataGuardAgentText({ agentText: recentUserText, messages }), messages, pool, userId, findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null, }); if (!afterBind.needsRepair) { resetPageDataFinishGuardAttempts(sessionId); return { repaired: autoBind.bound.length > 0, skipped: 'ok', autoBind, ...afterBind }; } if (!isPageDataFinishGuardEnabled(env)) { logger.warn?.( `[MindSpace][page-data-guard] incomplete page data delivery for session ${sessionId}: ` + `issues=${afterBind.htmlIssues.map((item) => `${item.relativePath}:${item.issue}`).join(',') || 'none'} ` + `unbound=${afterBind.unboundFiles.map((file) => file.relativePath).join(',') || 'none'}`, ); return { repaired: false, skipped: 'disabled', autoBind, ...afterBind }; } if (!tkmindProxy?.submitSessionReplyForUser) { return { repaired: false, skipped: 'no_proxy', autoBind, ...afterBind }; } const key = String(sessionId ?? ''); const attempts = repairAttemptsBySession.get(key) ?? 0; if (!key || attempts >= maxAttempts) { logger.warn?.( `[MindSpace][page-data-guard] repair limit reached for session ${sessionId}`, afterBind, ); return { repaired: false, skipped: 'limit', attempts, autoBind, ...afterBind }; } repairAttemptsBySession.set(key, attempts + 1); const prompt = buildPageDataCollectRepairPrompt(afterBind); // goosed validates request_id as a UUID. Keep the repair classification in // message metadata rather than making the request id non-conformant. const requestId = crypto.randomUUID(); const userMessage = { role: 'user', content: [{ type: 'text', text: prompt }], metadata: { displayText: '请补全 Page Data 问卷绑定与页面脚本', userVisible: false, agentVisible: true, memindRun: { pageDataFinishRepair: true }, }, }; try { await tkmindProxy.submitSessionReplyForUser(userId, sessionId, requestId, userMessage); logger.info?.( `[MindSpace][page-data-guard] triggered repair for session ${sessionId} ` + `(attempt ${attempts + 1}/${maxAttempts})`, ); return { repaired: true, triggered: true, attempts: attempts + 1, autoBind, ...afterBind }; } catch (err) { logger.warn?.( `[MindSpace][page-data-guard] repair failed for session ${sessionId}: ` + `${err instanceof Error ? err.message : err}`, ); return { repaired: false, skipped: 'error', error: err instanceof Error ? err.message : String(err), attempts: attempts + 1, autoBind, ...afterBind, }; } }