open-customer-session.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const os = require('os');
  4. const path = require('path');
  5. const { DatabaseSync } = require('node:sqlite');
  6. const spawn = require('cross-spawn');
  7. const { applyWorkspaceContext } = require('../mcp/src/core/runtime-context');
  8. const PACKAGE_ROOT = path.resolve(__dirname, '..');
  9. const { workspaceRoot: PROJECT_ROOT } = applyWorkspaceContext({ packageRoot: PACKAGE_ROOT, changeCwd: false });
  10. function loadEnvFile(filePath) {
  11. if (!fs.existsSync(filePath)) return;
  12. for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
  13. const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
  14. if (!match || process.env[match[1]] !== undefined) continue;
  15. let value = match[2].trim();
  16. if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
  17. value = value.slice(1, -1);
  18. }
  19. process.env[match[1]] = value;
  20. }
  21. }
  22. function maskContact(value) {
  23. const text = String(value || '');
  24. if (text.length <= 4) return '***';
  25. return `${text.slice(0, 2)}***${text.slice(-2)}`;
  26. }
  27. function safeLabel(value) {
  28. return String(value || '企微客户')
  29. .trim()
  30. .replace(/[\\/:*?"<>|\r\n]+/g, '-')
  31. .replace(/\s+/g, '-')
  32. .replace(/-+/g, '-')
  33. .replace(/^-|-$/g, '')
  34. .slice(0, 24) || '企微客户';
  35. }
  36. function parseArgs(argv) {
  37. const args = { list: false, dryRun: false, customer: '', index: 0 };
  38. for (let i = 0; i < argv.length; i += 1) {
  39. if (argv[i] === '--list') args.list = true;
  40. else if (argv[i] === '--dry-run') args.dryRun = true;
  41. else if (argv[i] === '--customer') args.customer = String(argv[++i] || '').trim();
  42. else if (argv[i] === '--index') args.index = Number(argv[++i] || 0);
  43. else if (argv[i] === '--help' || argv[i] === '-h') args.help = true;
  44. }
  45. return args;
  46. }
  47. function readCustomerSessions({ sessionFile, dbPath }) {
  48. if (!fs.existsSync(sessionFile)) throw new Error('尚未创建客户 Claude Code Session');
  49. if (!fs.existsSync(dbPath)) throw new Error('企微 Workbench 数据库不存在');
  50. const state = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
  51. const db = new DatabaseSync(dbPath, { readOnly: true });
  52. try {
  53. return Object.entries(state.sessions || {})
  54. .filter(([, session]) => session.role === 'customer-agent')
  55. .map(([conversationId, session]) => {
  56. const conversation = db.prepare('SELECT contact_name, contact_id FROM conversations WHERE id=?').get(conversationId);
  57. return {
  58. conversationId,
  59. session,
  60. customerName: conversation?.contact_name || session.customerName || '未命名客户',
  61. maskedContact: maskContact(conversation?.contact_id),
  62. projectRoot: state.project?.projectRoot || PROJECT_ROOT,
  63. };
  64. })
  65. .sort((a, b) => String(a.customerName).localeCompare(String(b.customerName), 'zh-CN'));
  66. } finally {
  67. db.close();
  68. }
  69. }
  70. function printList(rows) {
  71. if (!rows.length) {
  72. process.stdout.write('当前没有客户 Claude Code Session。\n');
  73. return;
  74. }
  75. process.stdout.write('可查看的客户 Claude Code Session:\n');
  76. rows.forEach((row, index) => {
  77. const status = row.session.initialized ? '已有对话' : '尚未初始化';
  78. process.stdout.write(`${index + 1}. ${row.customerName}(${row.maskedContact})· ${status}\n`);
  79. });
  80. }
  81. function selectSession(rows, args) {
  82. if (args.index) return rows[args.index - 1] || null;
  83. if (args.customer) {
  84. const exact = rows.filter(row => row.customerName === args.customer);
  85. if (exact.length === 1) return exact[0];
  86. const fuzzy = rows.filter(row => row.customerName.includes(args.customer));
  87. if (fuzzy.length === 1) return fuzzy[0];
  88. return null;
  89. }
  90. return rows.length === 1 ? rows[0] : null;
  91. }
  92. function findTranscript(sessionId) {
  93. const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
  94. if (!fs.existsSync(projectsRoot)) return false;
  95. const target = `${sessionId}.jsonl`;
  96. const queue = [projectsRoot];
  97. while (queue.length) {
  98. const current = queue.shift();
  99. for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
  100. const fullPath = path.join(current, entry.name);
  101. if (entry.isDirectory()) queue.push(fullPath);
  102. else if (entry.name === target) return true;
  103. }
  104. }
  105. return false;
  106. }
  107. function resolveClaudeCommand() {
  108. const candidates = [
  109. process.env.CLAUDE_CODE_EXECUTABLE,
  110. path.join(process.env.APPDATA || '', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
  111. process.platform === 'win32' ? 'claude.cmd' : 'claude',
  112. ].filter(Boolean);
  113. return candidates.find(candidate => !path.isAbsolute(candidate) || fs.existsSync(candidate)) || candidates.at(-1);
  114. }
  115. async function main() {
  116. loadEnvFile(path.join(PROJECT_ROOT, '.env.local'));
  117. const args = parseArgs(process.argv.slice(2));
  118. if (args.help) {
  119. process.stdout.write([
  120. '查看客户 Claude Code Session:',
  121. ' npm run agent:session:list',
  122. ' npm run agent:session -- --customer <客户名称>',
  123. '',
  124. '打开时会自动 fork 一份审阅会话;查看和追问不会污染生产客户 Session。',
  125. '',
  126. ].join('\n'));
  127. return;
  128. }
  129. const outputsRoot = path.resolve(process.env.QIWEI_OUTPUTS_DIR || path.join(PROJECT_ROOT, 'outputs'));
  130. const sessionFile = path.resolve(process.env.CLAUDE_CODE_SESSION_FILE || path.join(outputsRoot, 'messages', 'claude-code-sessions.json'));
  131. const dbPath = path.resolve(process.env.QIWEI_AGENT_DB_PATH || path.join(outputsRoot, 'messages', 'agent-workbench.db'));
  132. const rows = readCustomerSessions({ sessionFile, dbPath });
  133. if (args.list) {
  134. printList(rows);
  135. return;
  136. }
  137. const selected = selectSession(rows, args);
  138. if (!selected) {
  139. printList(rows);
  140. throw new Error('没有唯一匹配的客户,请通过 --customer <客户名称> 或 --index <序号> 选择');
  141. }
  142. if (!selected.session.initialized) throw new Error('该客户 Session 尚未产生 Claude Code 对话');
  143. const reviewName = `审阅-${safeLabel(selected.customerName)}`;
  144. const transcriptFound = findTranscript(selected.session.id);
  145. if (args.dryRun) {
  146. process.stdout.write(`${JSON.stringify({
  147. status: 'ok',
  148. customer: selected.customerName,
  149. maskedContact: selected.maskedContact,
  150. transcriptFound,
  151. openMode: 'forked-review',
  152. productionSessionProtected: true,
  153. }, null, 2)}\n`);
  154. return;
  155. }
  156. if (!transcriptFound) throw new Error('没有找到该客户的本地 Claude Code 会话记录');
  157. process.stdout.write(`正在打开 ${selected.customerName}(${selected.maskedContact})当前 Epoch 的 Claude Code 审阅会话。\n`);
  158. process.stdout.write('系统会先 fork 审阅副本;你在其中查看或追问,不会污染生产客户 Session。长期客户记忆保存在 Workbench DB 中。\n');
  159. const child = spawn(resolveClaudeCommand(), [
  160. '--resume', selected.session.id,
  161. '--fork-session',
  162. '--name', reviewName,
  163. ], {
  164. cwd: fs.existsSync(selected.projectRoot) ? selected.projectRoot : PROJECT_ROOT,
  165. stdio: 'inherit',
  166. });
  167. child.on('error', error => {
  168. process.stderr.write(`无法打开 Claude Code:${error.message}\n`);
  169. process.exitCode = 1;
  170. });
  171. child.on('exit', code => { process.exitCode = Number(code || 0); });
  172. }
  173. if (require.main === module) {
  174. main().catch(error => {
  175. process.stderr.write(`${error.message}\n`);
  176. process.exitCode = 1;
  177. });
  178. }
  179. module.exports = { maskContact, parseArgs, readCustomerSessions, selectSession };