Files
memind/mindspace-public-finish-sync.mjs
T
john 98721371a4 fix(mindspace): edit_file 落盘、Finish 聊天 merge 与回归守卫
- finish-sync 支持 edit_file 覆盖 public HTML
- Finish 同步 merge 本地流式消息,剥离 agent 内部前缀
- 新增 verify:mindspace-publish-guards 与 AGENTS.md 跨工具说明
- 发版脚本接入回归门禁;103 runtime 发布含备份回退

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 00:27:15 +08:00

332 lines
12 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import { extractStaticPageLinks } from './mindspace-chat-save.mjs';
/**
* Public HTML finish-sync invariants (regression guard — do not simplify away).
* edit_file patches must materialize onto MindSpace/<userId>/public/*.html.
* See docs/regression-guards/mindspace-publish-and-chat-finish.md
*/
const PUBLIC_HTML_PATH_PATTERN = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/i;
const PUBLIC_DOCX_HREF_PATTERN = /href=["']([^"'#?\s]+\.docx)["']/gi;
function normalizePublicDocxHref(href) {
const clean = String(href ?? '')
.split('?')[0]
.split('#')[0]
.replace(/^\.\//, '')
.trim();
if (!clean || clean.includes('://') || clean.startsWith('data:')) return null;
if (clean.startsWith('../oa/')) return clean.slice(3);
if (clean.startsWith('oa/')) return clean;
if (!clean.includes('/')) return `public/${clean}`;
return clean;
}
function extractPublicDocxReferencesFromHtml(publishDir) {
const root = path.resolve(String(publishDir ?? ''));
const publicDir = path.join(root, 'public');
if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) return [];
const refs = new Set();
for (const file of fs.readdirSync(publicDir)) {
if (!file.toLowerCase().endsWith('.html')) continue;
const content = fs.readFileSync(path.join(publicDir, file), 'utf8');
for (const match of content.matchAll(PUBLIC_DOCX_HREF_PATTERN)) {
const relativePath = normalizePublicDocxHref(match[1]);
if (relativePath) refs.add(relativePath);
}
}
return [...refs];
}
function findLatestCompleteOaDocx(publishDir, { minSize = 8000 } = {}) {
const oaDir = path.join(path.resolve(String(publishDir ?? '')), 'oa');
if (!fs.existsSync(oaDir) || !fs.statSync(oaDir).isDirectory()) return null;
const candidates = fs
.readdirSync(oaDir)
.filter((name) => name.toLowerCase().endsWith('.docx'))
.map((name) => {
const absolutePath = path.join(oaDir, name);
const stat = fs.statSync(absolutePath);
return { name, absolutePath, size: stat.size, mtimeMs: stat.mtimeMs };
})
.filter((item) => item.size >= minSize)
.sort((a, b) => b.mtimeMs - a.mtimeMs || b.size - a.size);
return candidates[0] ?? null;
}
export function syncPublicDocxDownloads({ publishDir, minCompleteSize = 8000 } = {}) {
const root = path.resolve(String(publishDir ?? ''));
if (!root) return { synced: [], skipped: [] };
const source = findLatestCompleteOaDocx(root, { minSize: minCompleteSize });
if (!source) return { synced: [], skipped: [] };
const synced = [];
const skipped = [];
for (const relativePath of extractPublicDocxReferencesFromHtml(root)) {
const destination = path.resolve(root, relativePath);
if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) {
skipped.push(relativePath);
continue;
}
let shouldCopy = !fs.existsSync(destination) || !fs.statSync(destination).isFile();
if (!shouldCopy) {
try {
shouldCopy = fs.statSync(destination).size < source.size;
} catch {
shouldCopy = true;
}
}
if (!shouldCopy) {
skipped.push(relativePath);
continue;
}
try {
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.copyFileSync(source.absolutePath, destination);
synced.push(relativePath);
} catch {
skipped.push(relativePath);
}
}
return { synced, skipped, source: source.name };
}
function messageText(message) {
const textParts = Array.isArray(message?.content)
? message.content
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
.map((item) => item.text.trim())
.filter(Boolean)
: [];
const displayText =
typeof message?.metadata?.displayText === 'string' ? message.metadata.displayText.trim() : '';
return [...textParts, displayText].filter(Boolean).join('\n');
}
export function normalizePublicHtmlRelativePath(relativePath) {
const parts = String(relativePath ?? '')
.replace(/^\/+/, '')
.split('/')
.filter((part) => part && part !== '.' && part !== '..');
if (parts.length === 0) return '';
if (parts[0].toLowerCase() === 'public') return parts.join('/');
if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`;
return parts.join('/');
}
function isWriteLikeHtmlTool(name, args) {
const action = String(args?.action ?? '').toLowerCase();
const normalizedName = String(name ?? '').trim();
const writeLikeDeveloper =
(normalizedName === 'developer' && (action === 'write' || action === 'edit')) ||
normalizedName === 'write' ||
normalizedName.endsWith('__write');
const writeLikeSandbox =
(normalizedName === 'write_file' ||
normalizedName === 'edit_file' ||
normalizedName.endsWith('__write_file') ||
normalizedName.endsWith('__edit_file')) &&
typeof args?.path === 'string';
return (writeLikeDeveloper && typeof args?.path === 'string') || writeLikeSandbox;
}
function isEditLikeHtmlTool(name, args) {
const normalizedName = String(name ?? '').trim();
return (
(normalizedName === 'edit_file' ||
normalizedName.endsWith('__edit_file') ||
(normalizedName === 'developer' && String(args?.action ?? '').toLowerCase() === 'edit')) &&
typeof args?.path === 'string'
);
}
function readPublicHtmlBaseline(publishDir, relativePath) {
const root = path.resolve(String(publishDir ?? ''));
if (!root) return '';
const destination = path.resolve(root, relativePath);
if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) return '';
if (!fs.existsSync(destination) || !fs.statSync(destination).isFile()) return '';
try {
return fs.readFileSync(destination, 'utf8');
} catch {
return '';
}
}
function applyPublicHtmlEdit(existing, args) {
if (typeof args?.new_str !== 'string') return null;
const oldStr = typeof args.old_str === 'string' ? args.old_str : '';
if (oldStr && !existing.includes(oldStr)) return null;
return oldStr ? existing.replace(oldStr, args.new_str) : args.new_str;
}
export function extractPublicHtmlWriteArtifacts(messages = [], { publishDir = null } = {}) {
const artifacts = new Map();
for (const message of messages) {
for (const item of message?.content ?? []) {
if (item?.type !== 'toolRequest') continue;
const toolCall = item.toolCall?.value;
const args = toolCall?.arguments ?? {};
if (!isWriteLikeHtmlTool(toolCall?.name, args)) continue;
const candidate = String(args.path ?? '').trim();
if (!candidate.toLowerCase().endsWith('.html')) continue;
const relativePath = normalizePublicHtmlRelativePath(candidate);
if (!relativePath) continue;
let content = null;
if (typeof args.content === 'string') {
content = args.content;
} else if (isEditLikeHtmlTool(toolCall?.name, args)) {
const baseline =
artifacts.get(relativePath)?.content ??
readPublicHtmlBaseline(publishDir, relativePath);
content = applyPublicHtmlEdit(baseline, args);
} else if (typeof args.new_str === 'string' && !args.old_str) {
content = args.new_str;
}
if (content == null) continue;
artifacts.set(relativePath, { relativePath, content });
}
}
return [...artifacts.values()];
}
const PUBLIC_HTML_STUB_MARKERS = ['临时补出', '服务号兜底', '服务号自动补出简版页面'];
export function isStubPublicHtmlContent(content) {
const value = String(content ?? '');
return PUBLIC_HTML_STUB_MARKERS.some((marker) => value.includes(marker));
}
function shouldReplaceExistingPublicHtml(destination, nextContent) {
if (!fs.existsSync(destination) || !fs.statSync(destination).isFile()) return true;
try {
const existing = fs.readFileSync(destination, 'utf8');
if (isStubPublicHtmlContent(existing)) return true;
return typeof nextContent === 'string' && nextContent.length > 0 && existing !== nextContent;
} catch {
return Boolean(nextContent);
}
}
export function materializeMissingPublicHtmlWrites({ messages, publishDir }) {
const root = path.resolve(String(publishDir ?? ''));
if (!root) return { materialized: [], skipped: [] };
const materialized = [];
const skipped = [];
for (const artifact of extractPublicHtmlWriteArtifacts(messages, { publishDir: root })) {
const destination = path.resolve(root, artifact.relativePath);
if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) {
skipped.push(artifact.relativePath);
continue;
}
if (fs.existsSync(destination) && fs.statSync(destination).isFile()) {
if (!shouldReplaceExistingPublicHtml(destination, artifact.content)) {
skipped.push(artifact.relativePath);
continue;
}
}
try {
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.writeFileSync(destination, artifact.content, 'utf8');
materialized.push(artifact.relativePath);
} catch {
skipped.push(artifact.relativePath);
}
}
return { materialized, skipped };
}
export function materializePublicHtmlWritesFromSessionEvent(
event,
{ publishDir, recentCount = 20 } = {},
) {
if (!event || !publishDir) {
return { materialized: [], skipped: [] };
}
if (event.type === 'Message' && event.message) {
return materializeMissingPublicHtmlWrites({
messages: [event.message],
publishDir,
});
}
if (event.type === 'UpdateConversation' && Array.isArray(event.conversation)) {
return materializeMissingPublicHtmlWrites({
messages: event.conversation.slice(-Math.max(1, recentCount)),
publishDir,
});
}
return { materialized: [], skipped: [] };
}
function messageHasPublicHtmlToolRequest(message, { publishDir = null } = {}) {
if (extractPublicHtmlWriteArtifacts([message], { publishDir }).length > 0) {
return true;
}
for (const item of message?.content ?? []) {
if (item?.type !== 'toolRequest') continue;
const toolCall = item.toolCall?.value;
const args = toolCall?.arguments ?? {};
if (!isWriteLikeHtmlTool(toolCall?.name, args)) continue;
const candidate = String(args.path ?? '').trim();
if (!candidate.toLowerCase().endsWith('.html')) continue;
if (normalizePublicHtmlRelativePath(candidate)) return true;
}
return false;
}
export function hasRecentOwnPublicHtmlReference(
messages,
currentUser,
{ recentCount = 80, publishDir = null } = {},
) {
if (!Array.isArray(messages) || messages.length === 0 || !currentUser?.id) return false;
const recentMessages = messages.slice(-Math.max(1, recentCount));
for (const message of recentMessages) {
if (messageHasPublicHtmlToolRequest(message, { publishDir })) {
return true;
}
const text = messageText(message);
if (!text) continue;
if (
extractStaticPageLinks(text, {
userId: currentUser.id,
username: currentUser.username ?? null,
}).length > 0
) {
return true;
}
if (PUBLIC_HTML_PATH_PATTERN.test(text)) {
return true;
}
}
return false;
}
export async function syncPublicHtmlAfterFinish({
messages,
currentUser,
publishDir,
syncWorkspaceAssets,
} = {}) {
if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) {
return { materialized: [], skipped: [], synced: false };
}
const { materialized, skipped } = materializeMissingPublicHtmlWrites({ messages, publishDir });
const docxSync = syncPublicDocxDownloads({ publishDir });
let synced = false;
if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) {
await syncWorkspaceAssets(currentUser.id, { categoryCode: 'public' });
synced = true;
}
return { materialized, skipped, synced, docxSync };
}