xshat-lite 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ import {
2
+ isStructuredResponseIncomplete,
3
+ parseAgentResponse
4
+ } from './response-parser.mjs';
5
+ import { getAgentRoleLabel } from './multi-agent.mjs';
6
+
7
+ const INTERNAL_COMMANDS = new Set([
8
+ 'get_app_state',
9
+ 'read_agent_page',
10
+ 'extract_selector_text',
11
+ 'inspect_dom'
12
+ ]);
13
+
14
+ function normalizeDisplayLine(line) {
15
+ const value = String(line ?? '').replace(/[ \t]+$/g, '');
16
+
17
+ if (/^\s*[•●▪◦·‣]\s+/.test(value)) {
18
+ return value.replace(/^(\s*)[•●▪◦·‣]\s+/, '$1- ');
19
+ }
20
+
21
+ if (/^\s*\d+[\)\]]\s+/.test(value)) {
22
+ return value.replace(/^(\s*)(\d+)[\)\]]\s+/, '$1$2. ');
23
+ }
24
+
25
+ return value;
26
+ }
27
+
28
+ function isFillerLine(line) {
29
+ const value = String(line ?? '').trim();
30
+ if (!value || value.length > 20) {
31
+ return false;
32
+ }
33
+
34
+ return [
35
+ /^(当然可以|可以|好的|好|没问题|行|如下)[。!!::]*$/i,
36
+ /^(下面是|以下是)(结果|说明|整理|内容)?[::]*$/i,
37
+ /^(sure|certainly|okay|ok)[.!:]*$/i,
38
+ /^(here(?:'s| is))( the)?( result| summary| answer)?[.:]*$/i
39
+ ].some((pattern) => pattern.test(value));
40
+ }
41
+
42
+ function isWeakClosingLine(line) {
43
+ const value = String(line ?? '').trim();
44
+ if (!value || value.length > 36) {
45
+ return false;
46
+ }
47
+
48
+ return [
49
+ /^希望这对你有帮助[。!!]*$/i,
50
+ /^有需要.*(继续|再调|再改|告诉我)[。!!]*$/i,
51
+ /^如果你需要.*(继续|细化|展开|补充)[。!!]*$/i,
52
+ /^如需.*(继续|细化|展开|补充)[。!!]*$/i,
53
+ /^(hope this helps)[.!]*$/i,
54
+ /^(let me know|tell me) if you (want|need).*[.!]*$/i
55
+ ].some((pattern) => pattern.test(value));
56
+ }
57
+
58
+ function pruneFillerLines(text = '') {
59
+ const lines = String(text ?? '').split('\n');
60
+
61
+ while (lines.length > 1 && isFillerLine(lines[0])) {
62
+ lines.shift();
63
+ }
64
+
65
+ while (lines.length > 1 && isWeakClosingLine(lines.at(-1))) {
66
+ lines.pop();
67
+ }
68
+
69
+ return lines.join('\n');
70
+ }
71
+
72
+ function normalizeDisplayText(text = '') {
73
+ const normalized = pruneFillerLines(
74
+ String(text ?? '')
75
+ )
76
+ .replace(/\u200B/g, '')
77
+ .replace(/\r/g, '')
78
+ .replace(/[ \t]+\n/g, '\n')
79
+ .split('\n')
80
+ .map((line) => normalizeDisplayLine(line))
81
+ .join('\n')
82
+ .replace(/\n{3,}/g, '\n\n')
83
+ .trim();
84
+
85
+ return normalized;
86
+ }
87
+
88
+ function containsMarkdownMarkers(text = '') {
89
+ const value = String(text ?? '');
90
+ return (
91
+ /(^|\n)```/.test(value) ||
92
+ /(^|\n)#{1,6}\s+/.test(value) ||
93
+ /(^|\n)>\s+/.test(value) ||
94
+ /(^|\n)[*-]\s+/.test(value) ||
95
+ /(^|\n)\d+\.\s+/.test(value) ||
96
+ /(^|\n)\|.+\|/.test(value) ||
97
+ /(^|\n)-{3,}($|\n)/.test(value)
98
+ );
99
+ }
100
+
101
+ function splitNaturalSentences(text = '') {
102
+ return String(text ?? '')
103
+ .match(/[^。!?!?;;]+[。!?!?;;]?/g)?.map((part) => part.trim()).filter(Boolean) || [];
104
+ }
105
+
106
+ function formatCliFriendlyText(text = '') {
107
+ const value = normalizeDisplayText(text);
108
+
109
+ if (!value || value.includes('\n') || containsMarkdownMarkers(value)) {
110
+ return value;
111
+ }
112
+
113
+ if (value.length < 90 || value.length > 520) {
114
+ return value;
115
+ }
116
+
117
+ const sentences = splitNaturalSentences(value);
118
+ if (sentences.length < 3 || sentences.length > 6) {
119
+ return value;
120
+ }
121
+
122
+ if (sentences.some((sentence) => sentence.length < 4 || sentence.length > 88)) {
123
+ return value;
124
+ }
125
+
126
+ return sentences.map((sentence) => `- ${sentence}`).join('\n');
127
+ }
128
+
129
+ function detectMarkdown(text) {
130
+ const value = normalizeDisplayText(text);
131
+ return containsMarkdownMarkers(value);
132
+ }
133
+
134
+ function shouldPreferInline(text, renderAs) {
135
+ const value = normalizeDisplayText(text);
136
+ if (!value || renderAs !== 'text') {
137
+ return false;
138
+ }
139
+
140
+ return !value.includes('\n') && value.length <= 88;
141
+ }
142
+
143
+ function shouldStreamAsMarkdown(text = '') {
144
+ const value = normalizeDisplayText(text);
145
+ if (!value) {
146
+ return false;
147
+ }
148
+
149
+ return (
150
+ value.includes('\n') ||
151
+ /(^|\n)#{1,6}\s+/.test(value) ||
152
+ /(^|\n)[*-]\s+/.test(value) ||
153
+ /(^|\n)\d+\.\s+/.test(value) ||
154
+ /(^|\n)>\s+/.test(value) ||
155
+ /(^|\n)```/.test(value)
156
+ );
157
+ }
158
+
159
+ export function isInterruptedTurnResponse(text = '') {
160
+ const value = String(text ?? '').trim();
161
+ if (!value) {
162
+ return false;
163
+ }
164
+
165
+ return [
166
+ /你已让系统停止这条回答/,
167
+ /你已停止这条回答/,
168
+ /you stopped this response/i,
169
+ /response was stopped/i,
170
+ /stopped generating/i
171
+ ].some((pattern) => pattern.test(value));
172
+ }
173
+
174
+ export function normalizeResponseEnvelope(rawText, {
175
+ agentMode = false
176
+ } = {}) {
177
+ const parsed = parseAgentResponse(rawText);
178
+ const text = formatCliFriendlyText(parsed.text || '');
179
+ const renderAs = detectMarkdown(text) ? 'markdown' : 'text';
180
+ const incompleteStructured = isStructuredResponseIncomplete(rawText);
181
+ const consoleBaseText =
182
+ incompleteStructured
183
+ ? '正在等待网页模型补全结构化回复...'
184
+ : parsed.command && INTERNAL_COMMANDS.has(parsed.command.name)
185
+ ? ''
186
+ : text;
187
+ const consoleText = formatCliFriendlyText(consoleBaseText);
188
+ const consoleTextRenderAs = detectMarkdown(consoleText) ? 'markdown' : 'text';
189
+ const kind = parsed.command
190
+ ? 'command'
191
+ : parsed.type === 'handoff'
192
+ ? 'handoff'
193
+ : 'message';
194
+
195
+ return {
196
+ ...parsed,
197
+ text,
198
+ displayText: text,
199
+ agentMode,
200
+ kind,
201
+ agentLabel: parsed.agent ? getAgentRoleLabel(parsed.agent) : '',
202
+ consoleText,
203
+ consoleTextRenderAs,
204
+ incompleteStructured,
205
+ renderAs,
206
+ inlinePreferred: shouldPreferInline(text, renderAs),
207
+ consoleInlinePreferred: shouldPreferInline(consoleText, consoleTextRenderAs),
208
+ streamMarkdownPreferred: shouldStreamAsMarkdown(text),
209
+ consoleStreamMarkdownPreferred: shouldStreamAsMarkdown(consoleText)
210
+ };
211
+ }
@@ -0,0 +1,195 @@
1
+ import fs from 'node:fs/promises';
2
+ import { basename, dirname, extname, resolve } from 'node:path';
3
+ import config from '../utils/config.mjs';
4
+
5
+ function nowStamp() {
6
+ return new Date().toISOString().replace(/[:.]/g, '-');
7
+ }
8
+
9
+ async function ensureDir(path) {
10
+ await fs.mkdir(path, { recursive: true });
11
+ }
12
+
13
+ async function pathExists(path) {
14
+ try {
15
+ await fs.access(path);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ async function getDirectoryEntries(dir, {
23
+ skip = new Set()
24
+ } = {}) {
25
+ try {
26
+ const names = await fs.readdir(dir);
27
+ return names.filter((name) => !skip.has(name));
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ async function getFileStatSafe(path) {
34
+ try {
35
+ return await fs.stat(path);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ async function removeTempFiles(dir) {
42
+ const entries = await getDirectoryEntries(dir);
43
+ let removed = 0;
44
+
45
+ for (const name of entries) {
46
+ if (!name.endsWith('.tmp')) {
47
+ continue;
48
+ }
49
+
50
+ try {
51
+ await fs.rm(resolve(dir, name), { force: true });
52
+ removed++;
53
+ } catch {
54
+ // ignore cleanup failure
55
+ }
56
+ }
57
+
58
+ return removed;
59
+ }
60
+
61
+ async function archiveOverflowFiles(sourceDir, archiveRoot, bucketName, retention, {
62
+ skip = new Set()
63
+ } = {}) {
64
+ const names = await getDirectoryEntries(sourceDir, { skip });
65
+ const files = [];
66
+
67
+ for (const name of names) {
68
+ const fullPath = resolve(sourceDir, name);
69
+ const stat = await getFileStatSafe(fullPath);
70
+ if (!stat?.isFile()) {
71
+ continue;
72
+ }
73
+
74
+ files.push({
75
+ name,
76
+ path: fullPath,
77
+ mtimeMs: stat.mtimeMs
78
+ });
79
+ }
80
+
81
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
82
+ const overflow = files.slice(Math.max(0, retention));
83
+ if (!overflow.length) {
84
+ return 0;
85
+ }
86
+
87
+ const archiveDir = resolve(archiveRoot, bucketName);
88
+ await ensureDir(archiveDir);
89
+ let moved = 0;
90
+
91
+ for (const file of overflow) {
92
+ const ext = extname(file.name);
93
+ const base = basename(file.name, ext);
94
+ const target = resolve(archiveDir, `${base}.${nowStamp()}${ext}`);
95
+ try {
96
+ await fs.rename(file.path, target);
97
+ moved++;
98
+ } catch {
99
+ // ignore single-file archive failure
100
+ }
101
+ }
102
+
103
+ return moved;
104
+ }
105
+
106
+ async function calculateDirSize(dir) {
107
+ let total = 0;
108
+ const stack = [dir];
109
+
110
+ while (stack.length > 0) {
111
+ const current = stack.pop();
112
+ let entries = [];
113
+
114
+ try {
115
+ entries = await fs.readdir(current, { withFileTypes: true });
116
+ } catch {
117
+ continue;
118
+ }
119
+
120
+ for (const entry of entries) {
121
+ const fullPath = resolve(current, entry.name);
122
+ if (entry.isDirectory()) {
123
+ stack.push(fullPath);
124
+ continue;
125
+ }
126
+
127
+ try {
128
+ const stat = await fs.stat(fullPath);
129
+ total += stat.size;
130
+ } catch {
131
+ // ignore broken files
132
+ }
133
+ }
134
+ }
135
+
136
+ return total;
137
+ }
138
+
139
+ export async function runRuntimeMaintenance() {
140
+ const summary = {
141
+ tempRemoved: 0,
142
+ sessionsArchived: 0,
143
+ tasksArchived: 0,
144
+ profileWarning: null
145
+ };
146
+
147
+ if (!config.storage?.cleanupOnStart) {
148
+ return summary;
149
+ }
150
+
151
+ const archiveRoot = config.storage.archiveDir;
152
+ await ensureDir(archiveRoot);
153
+
154
+ if (config.storage.removeTempFilesOnStart) {
155
+ summary.tempRemoved += await removeTempFiles(config.session.dir);
156
+ summary.tempRemoved += await removeTempFiles(config.tasks.dir);
157
+ summary.tempRemoved += await removeTempFiles(dirname(resolve(config.logger.dir, config.logger.filename)));
158
+ }
159
+
160
+ summary.sessionsArchived = await archiveOverflowFiles(
161
+ config.session.dir,
162
+ archiveRoot,
163
+ 'sessions',
164
+ Math.max(0, Number(config.storage.sessionRetention) || 0),
165
+ { skip: new Set(['_index.json']) }
166
+ );
167
+
168
+ summary.tasksArchived = await archiveOverflowFiles(
169
+ config.tasks.dir,
170
+ archiveRoot,
171
+ 'tasks',
172
+ Math.max(0, Number(config.storage.taskRetention) || 0),
173
+ { skip: new Set(['_index.json']) }
174
+ );
175
+
176
+ if (await pathExists(config.browser.userDataDir)) {
177
+ const bytes = await calculateDirSize(config.browser.userDataDir);
178
+ const sizeMb = bytes / (1024 * 1024);
179
+ const thresholdMb = Math.max(1, Number(config.storage.profileWarningSizeMb) || 1024);
180
+
181
+ if (sizeMb >= thresholdMb) {
182
+ summary.profileWarning = {
183
+ sizeMb: Number(sizeMb.toFixed(1)),
184
+ thresholdMb
185
+ };
186
+ }
187
+ }
188
+
189
+ return summary;
190
+ }
191
+
192
+ export default {
193
+ runRuntimeMaintenance
194
+ };
195
+
@@ -0,0 +1,180 @@
1
+ import { confirm, input as promptInput, select } from '@inquirer/prompts';
2
+ import { waitForKeypress } from '../utils/key-input.mjs';
3
+
4
+ function formatSessionLine(session, globalIndex) {
5
+ const date = new Date(session.updatedAt).toLocaleString('zh-CN', {
6
+ month: '2-digit',
7
+ day: '2-digit',
8
+ hour: '2-digit',
9
+ minute: '2-digit',
10
+ hour12: false
11
+ });
12
+ return `[${String(globalIndex).padStart(2)}] ${session.title} | ${session.provider} | ${session.messageCount}条 | ${date}`;
13
+ }
14
+
15
+ export async function browseSessions({
16
+ sessionManager,
17
+ ui,
18
+ initialKeyword = ''
19
+ }) {
20
+ let keyword = initialKeyword;
21
+ let selectedIndex = 0;
22
+ const pageSize = 10;
23
+
24
+ while (true) {
25
+ const sessions = await sessionManager.search(keyword);
26
+ const totalPages = Math.max(1, Math.ceil(Math.max(sessions.length, 1) / pageSize));
27
+ selectedIndex = Math.min(Math.max(selectedIndex, 0), Math.max(0, sessions.length - 1));
28
+ const currentPage = Math.floor(selectedIndex / pageSize) + 1;
29
+ const start = (currentPage - 1) * pageSize;
30
+ const pageSessions = sessions.slice(start, start + pageSize);
31
+ const localIndex = Math.max(0, selectedIndex - start);
32
+
33
+ ui.clear();
34
+ ui.printTitle(keyword ? `历史会话 · 搜索 "${keyword}"` : '历史会话');
35
+ ui.printNewline();
36
+ ui.printInputHints([
37
+ 'W/S 或 ↑↓ 选择',
38
+ 'A/D 或 ←→ 翻页',
39
+ 'Enter/R 恢复',
40
+ 'P 预览',
41
+ 'F 搜索',
42
+ 'C 清空搜索',
43
+ 'E 重命名',
44
+ 'X 删除',
45
+ 'Q/Esc 返回'
46
+ ]);
47
+ ui.printNewline();
48
+
49
+ if (sessions.length === 0) {
50
+ ui.printInfo('没有匹配的会话。按 F 搜索,按 C 清空搜索,按 Q 返回。');
51
+ const emptyKey = await waitForKeypress();
52
+ if (emptyKey === 'f') {
53
+ keyword = (await promptInput({
54
+ message: '输入关键字(留空查看全部)',
55
+ default: keyword
56
+ })).trim();
57
+ selectedIndex = 0;
58
+ }
59
+ if (emptyKey === 'c') {
60
+ keyword = '';
61
+ selectedIndex = 0;
62
+ }
63
+ if (emptyKey === 'q' || emptyKey === 'escape') {
64
+ return null;
65
+ }
66
+ continue;
67
+ }
68
+
69
+ ui.printSelectableList(
70
+ `会话列表 · 第 ${currentPage}/${totalPages} 页,共 ${sessions.length} 条`,
71
+ pageSessions.map((session, index) => formatSessionLine(session, start + index + 1)),
72
+ localIndex
73
+ );
74
+ ui.printNewline();
75
+
76
+ const key = await waitForKeypress();
77
+ const meta = sessions[selectedIndex];
78
+
79
+ if (key === 'up' || key === 'w') {
80
+ selectedIndex = Math.max(0, selectedIndex - 1);
81
+ continue;
82
+ }
83
+
84
+ if (key === 'down' || key === 's') {
85
+ selectedIndex = Math.min(sessions.length - 1, selectedIndex + 1);
86
+ continue;
87
+ }
88
+
89
+ if (key === 'left' || key === 'a') {
90
+ selectedIndex = Math.max(0, selectedIndex - pageSize);
91
+ continue;
92
+ }
93
+
94
+ if (key === 'right' || key === 'd') {
95
+ selectedIndex = Math.min(sessions.length - 1, selectedIndex + pageSize);
96
+ continue;
97
+ }
98
+
99
+ if (key === 'enter' || key === 'r') {
100
+ return meta;
101
+ }
102
+
103
+ if (key === 'p') {
104
+ const details = await sessionManager.load(meta.id);
105
+ ui.clear();
106
+ ui.printTitle('会话预览');
107
+ ui.printNewline();
108
+ ui.printSessionPreview(meta, details);
109
+ if (Array.isArray(details?.messages) && details.messages.length > 0) {
110
+ const previewLines = details.messages
111
+ .slice(-4)
112
+ .map((item) => `${item.role === 'user' ? 'User' : 'Assistant'}: ${String(item.content || '').replace(/\s+/g, ' ').slice(0, 120)}`);
113
+ ui.printNewline();
114
+ ui.printPanel('最近消息', previewLines);
115
+ }
116
+ ui.printNewline();
117
+ ui.printInfo('按任意键返回列表...');
118
+ await waitForKeypress();
119
+ continue;
120
+ }
121
+
122
+ if (key === 'f') {
123
+ keyword = (await promptInput({
124
+ message: '输入关键字(留空查看全部)',
125
+ default: keyword
126
+ })).trim();
127
+ selectedIndex = 0;
128
+ continue;
129
+ }
130
+
131
+ if (key === 'c') {
132
+ keyword = '';
133
+ selectedIndex = 0;
134
+ continue;
135
+ }
136
+
137
+ if (key === 'e') {
138
+ const nextTitle = await promptInput({
139
+ message: '输入新的会话标题',
140
+ default: meta.title
141
+ });
142
+ if (nextTitle.trim()) {
143
+ const renamed = await sessionManager.rename(meta.id, nextTitle);
144
+ if (renamed) {
145
+ ui.printSuccess('会话标题已更新');
146
+ await new Promise((resolve) => setTimeout(resolve, 700));
147
+ }
148
+ }
149
+ continue;
150
+ }
151
+
152
+ if (key === 'x') {
153
+ const approved = await confirm({
154
+ message: `确认删除会话“${meta.title}”?`,
155
+ default: false
156
+ });
157
+ if (approved) {
158
+ await sessionManager.delete(meta.id);
159
+ ui.printSuccess('会话已删除');
160
+ await new Promise((resolve) => setTimeout(resolve, 700));
161
+ selectedIndex = Math.max(0, Math.min(selectedIndex, Math.max(0, sessions.length - 2)));
162
+ }
163
+ continue;
164
+ }
165
+
166
+ if (key === 'q' || key === 'escape') {
167
+ return null;
168
+ }
169
+ }
170
+ }
171
+
172
+ export async function resumeLatestSession(sessionManager) {
173
+ const sessions = await sessionManager.list();
174
+ return sessions[0] || null;
175
+ }
176
+
177
+ export default {
178
+ browseSessions,
179
+ resumeLatestSession
180
+ };