Files
memind/mindspace-page-data-finish-guard.mjs
john 441703de32
Memind CI / Test, build, and release guards (push) Successful in 1m58s
fix: stabilize page data release gate scenarios
2026-07-26 17:41:45 +08:00

769 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { buildPageDataValidationRepairPrompt } from './page-data-validation-suggestions.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|\/admin)\.html$/i.test(relativePath);
const looksNeutral = /(?:^|\/)index\.html$/i.test(relativePath);
if (looksAdmin && hasInsert && !hasRead) {
issues.push('admin_page_should_not_insert_only');
}
if (!looksAdmin && !looksNeutral && 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 发布。',
'数据必须走平台 APIMindSpacePageData.createClient + /api/public/pages/...)写入用户私有数据空间,禁止 /api/page-data/ 旁路、localStorage、sessionStorage、IndexedDB 或自建后端。',
].join('');
}
export function buildPageDataCollectRepairPrompt({
htmlIssues = [],
unboundFiles = [],
} = {}) {
// Tier A suggestions are folded into the goosed repair turn so the agent
// receives actionable Chinese remediation, not only raw issue ids.
return buildPageDataValidationRepairPrompt({
htmlIssues,
unboundFiles,
message: '本轮用户已要求使用专属 PostgreSQL 数据空间,但 Page Data 页面交付尚未验证完成。',
});
}
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,
};
}
}