fix: repair chat history from DB when Goose session has empty placeholders

Portal direct chat → goosed escalation can leave hollow Goose messages while
h5_conversation_messages retains the full dialogue. Backfill session detail
from DB, extend snapshot list fallback, and keep stored session id on transient
boot failures so refresh no longer looks like a blank new chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-05 11:04:25 +08:00
parent d3239ff292
commit 614837e199
7 changed files with 371 additions and 9 deletions
+1
View File
@@ -2,6 +2,7 @@
# 反向 SSH 隧道:105 通过 127.0.0.1:19081 访问本机 Portal (:8081)
set -euo pipefail
# 103 上由 john 用户的 LaunchAgent 执行;SSH 目标为 105 的 root~/.ssh/config Host ssh105-public
HOST="${MEMIND_PORTAL_TUNNEL_HOST:-ssh105-public}"
LOCAL_PORT="${MEMIND_PORTAL_TUNNEL_LOCAL_PORT:-8081}"
REMOTE_PORT="${MEMIND_PORTAL_TUNNEL_REMOTE_PORT:-19081}"
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* Simulate GET /api/sessions/:id repair for a session using live 103 data.
* Usage: node scripts/verify-session-repair-local.mjs [sessionId] [userId]
*/
import { createConnection } from 'mysql2/promise';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
countNonEmptyConversationMessages,
loadSessionConversationRows,
repairConversationFromDbRows,
shouldRepairConversationFromDb,
} from '../conversation-repair.mjs';
import { extractConversationMessageText } from '../conversation-memory.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const sessionId = process.argv[2] ?? '20260705_2';
const userId = process.argv[3] ?? 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e';
function previewMessage(message, index) {
const text = extractConversationMessageText(message).replace(/\s+/g, ' ').slice(0, 90);
return `${index}. [${message.role}] ${text || '(empty)'}`;
}
async function load103Env() {
const { execSync } = await import('node:child_process');
const envText = execSync('ssh -o BatchMode=yes john@58.38.22.103 cat /Users/john/Project/Memind/.env', {
encoding: 'utf8',
});
const dbLine = envText.match(/^DATABASE_URL=(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '');
const secret = envText.match(/^TKMIND_SERVER__SECRET_KEY=(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '');
if (!dbLine || !secret) throw new Error('missing DATABASE_URL or TKMIND_SERVER__SECRET_KEY on 103');
const url = new URL(dbLine);
return {
pool: await createConnection({
host: url.hostname,
port: Number(url.port || 3306),
user: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
database: url.pathname.replace(/^\//, ''),
}),
secret,
};
}
async function fetchGooseSession(secret, sid) {
const { execSync } = await import('node:child_process');
const json = execSync(
`ssh -o BatchMode=yes john@58.38.22.103 'SECRET=$(grep ^TKMIND_SERVER__SECRET_KEY= /Users/john/Project/Memind/.env | cut -d= -f2- | tr -d "\\""); curl -k -sf "https://127.0.0.1:18006/sessions/${sid}" -H "X-Secret-Key: $SECRET"'`,
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 },
);
return JSON.parse(json);
}
async function fetchProductionApi(sessionId) {
const { execSync } = await import('node:child_process');
const login = execSync(
`curl -sk -c /tmp/verify-session-repair-cj.txt -X POST https://m.tkmind.cn/auth/login -H 'Content-Type: application/json' -d '{"username":"john","password":"981122tj"}'`,
{ encoding: 'utf8' },
);
const auth = JSON.parse(login);
if (!auth.authenticated) throw new Error('login failed');
const detail = execSync(
`curl -sk -b /tmp/verify-session-repair-cj.txt 'https://m.tkmind.cn/api/sessions/${encodeURIComponent(sessionId)}'`,
{ encoding: 'utf8' },
);
return JSON.parse(detail);
}
async function main() {
console.log(`\n=== 模拟验证 session ${sessionId} (user ${userId}) ===\n`);
const { pool, secret } = await load103Env();
const gooseSession = await fetchGooseSession(secret, sessionId);
const dbRows = await loadSessionConversationRows(pool, sessionId, userId);
await pool.end();
const gooseConversation = (gooseSession.conversation ?? []).filter(
(m) => m?.metadata?.userVisible !== false,
);
const repairedConversation = repairConversationFromDbRows(gooseConversation, dbRows);
console.log('--- Goose 原始(API 发版前会返回这类数据)---');
console.log(`消息数: ${gooseConversation.length}, 非空: ${countNonEmptyConversationMessages(gooseConversation)}`);
gooseConversation.forEach((m, i) => console.log(previewMessage(m, i)));
console.log('\n--- DB h5_conversation_messages ---');
console.log(`行数: ${dbRows.length}`);
dbRows.forEach((row, i) => {
const text = String(row.text ?? '').replace(/\s+/g, ' ').slice(0, 90);
console.log(`${i}. [${row.role}] seq=${row.sequence_no} ${text || '(empty)'}`);
});
console.log(`\nshouldRepair: ${shouldRepairConversationFromDb(gooseConversation, dbRows)}`);
console.log('\n--- 本地 repair 后(发版后 API 应接近此结果)---');
console.log(`消息数: ${repairedConversation.length}, 非空: ${countNonEmptyConversationMessages(repairedConversation)}`);
repairedConversation.forEach((m, i) => console.log(previewMessage(m, i)));
console.log('\n--- 当前生产 API(未发版,无 repair---');
try {
const prod = await fetchProductionApi(sessionId);
const prodConv = (prod.conversation ?? []).filter((m) => m?.metadata?.userVisible !== false);
console.log(`name: ${prod.name}`);
console.log(`消息数: ${prodConv.length}, 非空: ${countNonEmptyConversationMessages(prodConv)}`);
prodConv.forEach((m, i) => console.log(previewMessage(m, i)));
} catch (err) {
console.log(`生产 API 拉取失败: ${err instanceof Error ? err.message : err}`);
}
console.log('\n=== 结论 ===');
const repairedNonEmpty = countNonEmptyConversationMessages(repairedConversation);
const gooseNonEmpty = countNonEmptyConversationMessages(gooseConversation);
if (repairedNonEmpty > gooseNonEmpty) {
console.log(`✔ repair 后非空消息 ${gooseNonEmpty}${repairedNonEmpty},发版后 john 应能看到完整历史`);
} else {
console.log(`✖ repair 未改善非空消息数 (${gooseNonEmpty}${repairedNonEmpty})`);
process.exitCode = 1;
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});