Skip to content

Commit 79217ca

Browse files
authored
merge: release v0.1.29 session improvements
feat(sessions): improve export, search, and UI clarity
2 parents 0cebd49 + 8c92962 commit 79217ca

23 files changed

Lines changed: 451 additions & 143 deletions

‎cli.js‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5172,6 +5172,7 @@ function createSessionQueryScanState(tokens, options = {}) {
51725172
count: 0,
51735173
snippets: [],
51745174
positions: [],
5175+
complete: true,
51755176
leadingSystem: roleFilter !== 'system'
51765177
};
51775178
}
@@ -5210,11 +5211,13 @@ function consumeSessionQueryMessage(state, message) {
52105211
timestamp: typeof message.timestamp === 'string' ? message.timestamp : ''
52115212
});
52125213
}
5214+
if (state.count >= state.maxMatches) state.complete = false;
52135215
return state.count >= state.maxMatches;
52145216
}
52155217

52165218
function buildSessionQueryScanResult(state) {
52175219
return {
5220+
complete: !!(state && state.complete),
52185221
hit: !!(state && state.count > 0),
52195222
count: state && Number.isFinite(state.count) ? state.count : 0,
52205223
snippets: state && Array.isArray(state.snippets) ? state.snippets : [],
@@ -5259,6 +5262,7 @@ async function scanSessionContentForQuery(session, tokens, options = {}) {
52595262
const state = createSessionQueryScanState(tokens, options);
52605263
if (session.source === 'opencode') {
52615264
const extracted = queryOpencodeSessionMessages(session.sessionId || parseOpencodeVirtualSessionId(filePath), { maxMessages: Infinity });
5265+
state.complete = !!extracted && !extracted.truncated;
52625266
const messages = extracted && Array.isArray(extracted.messages) ? extracted.messages : [];
52635267
for (let i = 0; i < messages.length; i += 1) {
52645268
const message = { ...messages[i], recordLineIndex: i };
@@ -5268,6 +5272,7 @@ async function scanSessionContentForQuery(session, tokens, options = {}) {
52685272
}
52695273
if (session.source === 'zed') {
52705274
const zedExtracted = queryZedSessionMessages(session.sessionId || parseZedVirtualSessionId(filePath), { maxMessages: Infinity });
5275+
state.complete = !!zedExtracted && !zedExtracted.truncated;
52715276
const zedMessages = zedExtracted && Array.isArray(zedExtracted.messages) ? zedExtracted.messages : [];
52725277
for (let i = 0; i < zedMessages.length; i += 1) {
52735278
const message = { ...zedMessages[i], recordLineIndex: i };
@@ -5283,6 +5288,7 @@ async function scanSessionContentForQuery(session, tokens, options = {}) {
52835288
} catch (_) {
52845289
json = null;
52855290
}
5291+
state.complete = !!json;
52865292
const rawMessages = json && Array.isArray(json.messages) ? json.messages : [];
52875293
for (const entry of rawMessages) {
52885294
if (!entry || typeof entry !== 'object') continue;
@@ -5310,12 +5316,14 @@ async function scanSessionContentForQuery(session, tokens, options = {}) {
53105316
const buf = Buffer.alloc(targetBytes);
53115317
const bytes = fs.readSync(fd, buf, 0, targetBytes, 0);
53125318
fs.closeSync(fd);
5319+
state.complete = bytes >= stat.size;
53135320
text = bytes > 0 ? buf.slice(0, bytes).toString('utf-8') : '';
53145321
} catch (_) {
53155322
try {
53165323
text = fs.readFileSync(filePath, 'utf-8');
53175324
} catch (_) {
53185325
text = '';
5326+
state.complete = false;
53195327
}
53205328
}
53215329

@@ -5340,6 +5348,7 @@ async function scanSessionContentForQuery(session, tokens, options = {}) {
53405348
const currentLineIndex = lineIndex;
53415349
lineIndex += 1;
53425350
if (maxBytes > 0 && bytesRead >= maxBytes) {
5351+
state.complete = false;
53435352
break;
53445353
}
53455354

@@ -5369,6 +5378,7 @@ async function scanSessionContentForQuery(session, tokens, options = {}) {
53695378

53705379
return buildSessionQueryScanResult(state);
53715380
} catch (e) {
5381+
state.complete = false;
53725382
return scanSessionContentForQueryInRecords(readJsonlRecords(filePath), session.source, state);
53735383
} finally {
53745384
if (rl) {
@@ -5383,7 +5393,9 @@ async function scanSessionContentForQuery(session, tokens, options = {}) {
53835393
async function applySessionQueryFilter(sessions, options = {}) {
53845394
const tokens = Array.isArray(options.tokens) ? options.tokens : [];
53855395
if (tokens.length === 0) {
5386-
return sessions;
5396+
const result = sessions.slice();
5397+
Object.defineProperty(result, 'searchInfo', { value: null });
5398+
return result;
53875399
}
53885400

53895401
const mode = normalizeQueryMode(options.queryMode);
@@ -5397,10 +5409,12 @@ async function applySessionQueryFilter(sessions, options = {}) {
53975409
: 0;
53985410

53995411
let scanned = 0;
5412+
let complete = true;
54005413
const results = [];
54015414

54025415
for (const session of sessions) {
54035416
if (scope === 'content' && scanned >= contentScanLimit) {
5417+
complete = false;
54045418
break;
54055419
}
54065420

@@ -5420,6 +5434,10 @@ async function applySessionQueryFilter(sessions, options = {}) {
54205434
snippetLimit: 2
54215435
});
54225436
contentHit = contentInfo.hit;
5437+
// 命中已完成候选的存在性判定,无需穷尽匹配或消息。
5438+
if (!contentHit && !contentInfo.complete) complete = false;
5439+
} else if (shouldScanContent) {
5440+
complete = false;
54235441
}
54245442

54255443
const hit = scope === 'summary'
@@ -5441,6 +5459,9 @@ async function applySessionQueryFilter(sessions, options = {}) {
54415459
results.push(session);
54425460
}
54435461

5462+
Object.defineProperty(results, 'searchInfo', {
5463+
value: { scanned, candidates: sessions.length, complete }
5464+
});
54445465
return results;
54455466
}
54465467
function collectRecentJsonlFiles(rootDir, options = {}) {
@@ -7632,7 +7653,9 @@ async function listAllSessions(params = {}) {
76327653
contentScanBytes: params.contentScanBytes
76337654
});
76347655
}
7656+
const searchInfo = hasQuery ? result.searchInfo : null;
76357657
result = mergeAndLimitSessions(result, limit);
7658+
Object.defineProperty(result, 'searchInfo', { value: searchInfo });
76367659
setSessionListCache(cacheKey, result);
76377660
return result;
76387661
}
@@ -7665,6 +7688,7 @@ async function listAllSessionsData(params = {}) {
76657688
delete normalized.__messageCountExact;
76667689
return normalized;
76677690
});
7691+
Object.defineProperty(result, 'searchInfo', { value: sessions.searchInfo || null });
76687692
if (!hasQuery) {
76697693
setSessionListCache(cacheKey, result);
76707694
}
@@ -7676,7 +7700,7 @@ async function listSessionBrowse(params = {}) {
76767700
...params,
76777701
browseLightweight: true
76787702
});
7679-
return Array.isArray(sessions)
7703+
const result = Array.isArray(sessions)
76807704
? sessions.map((item) => {
76817705
if (!item || typeof item !== 'object' || Array.isArray(item)) {
76827706
return item;
@@ -7686,6 +7710,8 @@ async function listSessionBrowse(params = {}) {
76867710
return normalized;
76877711
})
76887712
: [];
7713+
Object.defineProperty(result, 'searchInfo', { value: sessions.searchInfo || null });
7714+
return result;
76897715
}
76907716

76917717
async function listSessionUsage(params = {}) {
@@ -10008,8 +10034,7 @@ async function exportSessionData(params = {}) {
1000810034
if (!extracted) {
1000910035
return { error: 'Failed to parse session file' };
1001010036
}
10011-
}
10012-
if (source === 'zed') {
10037+
} else if (source === 'zed') {
1001310038
const zedSessionId = params.sessionId || parseZedVirtualSessionId(filePath);
1001410039
extracted = queryZedSessionMessages(zedSessionId, { maxMessages });
1001510040
if (!extracted) {
@@ -14760,9 +14785,11 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
1476014785
if (source && !isSessionSourceOrAll(source)) {
1476114786
result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, workbuddy, pi, opencode, or all' };
1476214787
} else {
14788+
const sessions = await listSessionBrowse(params);
1476314789
result = {
14764-
sessions: await listSessionBrowse(params),
14765-
source: source || 'all'
14790+
sessions,
14791+
source: source || 'all',
14792+
searchInfo: sessions.searchInfo
1476614793
};
1476714794
}
1476814795
}

‎desktop/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codexmate-desktop",
3-
"version": "0.1.28",
3+
"version": "0.1.29",
44
"description": "Codex Mate 桌面启动器(Electron 壳,内嵌 codexmate Web UI)",
55
"main": "electron.js",
66
"private": true,

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codexmate",
3-
"version": "0.1.28",
3+
"version": "0.1.29",
44
"description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
55
"main": "cli.js",
66
"bin": {

‎site/guide/sessions.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
1. 按[快速开始](/guide/quick-start)启动,进入 Web UI 会话视图。
88
2. 在「会话来源」选择已有记录的工具,再按项目路径或时间缩小范围。可输入关键词并按回车检索;界面有检索入口不代表所有来源的记录格式都已经验证。
99
3. 打开会话,核对项目、时间和讨论内容。
10-
4. 点击「导出记录」,打开下载的 Markdown,检查需要的文字是否完整,并留意截断提示。
10+
4. 在「导出范围」选择最近 1000 条或全部可提取消息,再点击「导出记录」。打开下载的 Markdown 核对内容;截断说明会保留在当前会话预览中,可选择全部后重新导出。
1111

12-
没有记录时先确认原工具在本机保存过会话,再清除筛选条件并刷新。配置 Provider 不会生成历史会话。
12+
关键词查询会显示实际扫描数与本次候选数。未完成时请缩小来源或项目范围;候选范围不等于全部历史记录。筛选无结果时可直接点击「清除筛选」。没有记录时先确认原工具在本机保存过会话,再刷新。配置 Provider 不会生成历史会话。
1313

1414
## 来源与存储位置
1515

@@ -26,7 +26,7 @@
2626
| `opencode` | OpenCode | 数据目录中的 `opencode.db`,默认 `~/.local/share/opencode` |
2727
| `zed` | Zed | Zed 数据目录下的 `threads/threads.db` |
2828

29-
OpenCode 导出分支存在尚未完成运行验证的兼容性疑点,请先用一条样例确认导出结果,不把来源出现在列表中视为导出成功保证。首次体验可先使用已有的 Codex 或 Claude Code 记录。
29+
OpenCode 导出的来源分支现已互斥,避免数据库读取结果被通用文件读取覆盖。各工具版本的存储格式仍可能不同,先用一条记录核对结果;来源出现在列表中不代表所有版本均已验证。
3030

3131
## CLI 导出
3232

‎tests/e2e/test-setup.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ module.exports = async function testSetup(ctx) {
3636
assert(configContent.includes(`base_url = "${mockProviderUrl}"`), 'base_url missing or mismatched');
3737

3838
const authPath = path.join(tmpHome, '.codex', 'auth.json');
39-
const auth = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
40-
assert(auth.OPENAI_API_KEY === 'sk-test', 'auth api_key mismatch');
39+
assert(!fs.existsSync(authPath), 'auth.json should not be written in single-file auth mode');
40+
assert(configContent.includes('experimental_bearer_token = "sk-test"'), 'bearer token missing from config.toml');
4141

4242
const modelsPath = path.join(tmpHome, '.codex', 'models.json');
4343
const models = JSON.parse(fs.readFileSync(modelsPath, 'utf-8'));

‎tests/unit/config-tabs-ui.test.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ test('config template keeps expected config tabs in top and side navigation', ()
432432
assert.match(openclawModal, /<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="openclaw-config-modal-title">/);
433433
assert.match(openclawModal, /<div class="modal-title" id="openclaw-config-modal-title">{{ openclawEditorTitle }}<\/div>/);
434434
assert.match(openclawModal, /:readonly="openclawSaving \|\| openclawApplying"/);
435-
assert.match(openclawModal, /<button class="btn btn-cancel" @click="closeOpenclawConfigModal" :disabled="openclawSaving \|\| openclawApplying">取消<\/button>/);
435+
assert.match(openclawModal, /<button class="btn btn-cancel" @click="closeOpenclawConfigModal" :disabled="openclawSaving \|\| openclawApplying">\{\{ t\('common\.cancel'\) \}\}<\/button>/);
436436
assert.match(openclawModal, /<button class="btn btn-confirm" @click="saveOpenclawConfig" :disabled="openclawSaving \|\| openclawApplying \|\| \(openclawEditing\.lockName && isDefaultOpenclawConfig\(openclawEditing\.name\)\)">/);
437437
assert.match(openclawModal, /<button class="btn btn-confirm secondary" @click="saveAndApplyOpenclawConfig" :disabled="openclawSaving \|\| openclawApplying">/);
438438
assert.doesNotMatch(baseTheme, /fonts\.googleapis\.com/);

‎tests/unit/run.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ await import(pathToFileURL(path.join(__dirname, 'api-module.test.mjs')));
1313
await import(pathToFileURL(path.join(__dirname, 'web-ui-startup-init.test.mjs')));
1414
await import(pathToFileURL(path.join(__dirname, 'reset-main.test.mjs')));
1515
await import(pathToFileURL(path.join(__dirname, 'session-query.test.mjs')));
16+
await import(pathToFileURL(path.join(__dirname, 'session-export-range.test.mjs')));
1617
await import(pathToFileURL(path.join(__dirname, 'web-ui-url-canonicalization.test.mjs')));
1718
await import(pathToFileURL(path.join(__dirname, 'mcp-stdio.test.mjs')));
1819
await import(pathToFileURL(path.join(__dirname, 'workflow-engine.test.mjs')));
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import assert from 'node:assert/strict';
2+
import { createCodexConfigMethods } from '../../web-ui/modules/app.methods.codex-config.mjs';
3+
import { execFileSync } from 'node:child_process';
4+
import { fileURLToPath } from 'node:url';
5+
6+
test('session backend export and search contracts', () => {
7+
execFileSync(process.execPath, [fileURLToPath(new URL('./session-search-export.test.cjs', import.meta.url))], { timeout: 10000 });
8+
});
9+
test('session export range preserves default and clears truncation after full export', async () => {
10+
const requests = [], downloads = [];
11+
const methods = createCodexConfigMethods({ api: async (_, params) => {
12+
requests.push(params);
13+
return { content: 'message', truncated: params.maxMessages !== 'all', maxMessages: 1000 };
14+
} });
15+
const context = { sessionExporting: {}, sessionExportRange: 'default', getSessionExportKey: () => 's',
16+
downloadTextFile: (_, text) => downloads.push(text), showMessage() {}, t: key => key };
17+
const session = { source: 'codex', sessionId: 's', filePath: '/sample' };
18+
await methods.exportSession.call(context, session);
19+
assert.equal(Object.hasOwn(requests[0], 'maxMessages'), false);
20+
assert.deepEqual(context.sessionExportNotice, { key: 's' });
21+
context.sessionExportRange = 'all';
22+
await methods.exportSession.call(context, session);
23+
assert.equal(requests[1].maxMessages, 'all');
24+
assert.equal(context.sessionExportNotice, null);
25+
assert.equal(context.sessionExporting.s, false);
26+
assert.deepEqual(downloads, ['message', 'message']);
27+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const assert = require('node:assert/strict'), vm = require('node:vm'), fs = require('node:fs');
2+
const source = fs.readFileSync(require('node:path').join(__dirname, '../../cli.js'), 'utf8');
3+
const names = ['normalizeQueryMode','normalizeQueryScope','normalizeRoleFilter','buildSessionSummaryText','createSessionQueryScanState','consumeSessionQueryMessage','buildSessionQueryScanResult','scanSessionContentForQueryInRecords','scanSessionContentForQuery','applySessionQueryFilter','getSessionListCache','setSessionListCache','listSessionBrowse','exportSessionData'];
4+
let generic = 0, specialized = 0;
5+
const messages = [{ role: 'user', text: 'needle' }];
6+
const ctx = vm.createContext({ ...require('../../lib/cli-sessions'), ...require('../../lib/cli-session-utils'), Buffer, path: require('node:path'), readline: require('node:readline'), DEFAULT_CONTENT_SCAN_LIMIT: 10, MAX_EXPORT_MESSAGES: 100, SESSION_LIST_CACHE_TTL_MS: 1000, g_sessionListCache: new Map(), normalizeSessionSourceName: x => x, resolveSessionFilePath: () => '/memory', getSessionFileArg: () => '', queryOpencodeSessionMessages: () => { specialized++; return { messages: [...messages] }; }, extractMessagesFromFile: async () => { generic++; return { messages: [...messages] }; }, normalizeGeminiMessageRole: () => 'user', extractGeminiMessageText: x => x, getSessionSourceLabel: x => x, buildSessionMarkdown: x => x.messages.map(m => m.text).join(''), fs: { readFileSync: () => JSON.stringify({ messages: [{ type: 'user', content: 'needle' }] }), createReadStream: () => require('node:stream').Readable.from([JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'needle' }] } }) + '\n']) } });
7+
vm.runInContext(names.map(name => { const m = source.match(new RegExp('^(?:async )?function ' + name + '\\([^]*?^}', 'm')); assert.ok(m, name); return m[0]; }).join('\n'), ctx);
8+
(async () => {
9+
for (const kind of ['opencode', 'codex', 'gemini']) assert.equal((await ctx.exportSessionData({ source: kind, sessionId: 's' })).content, 'needle');
10+
assert.equal(specialized, 1); assert.equal(generic, 1);
11+
const sessions = [1, 2, 3].map(sessionId => ({ sessionId, source: 'codex', title: 'needle' }));
12+
const result = await ctx.applySessionQueryFilter(sessions, { tokens: ['needle'], queryScope: 'content', contentScanLimit: 2 });
13+
assert.equal(result.length, 2); assert.equal(JSON.stringify(result.searchInfo), '{"scanned":2,"candidates":3,"complete":false}');
14+
const full = await ctx.applySessionQueryFilter(sessions, { tokens: ['needle'], queryScope: 'content', contentScanLimit: 3 });
15+
assert.equal(full.searchInfo.complete, true); assert.equal(full.searchInfo.scanned, 3);
16+
const summary = await ctx.applySessionQueryFilter(sessions, { tokens: ['needle'], queryScope: 'summary' });
17+
assert.equal(summary.searchInfo.scanned, 0); assert.equal(summary.length, 3);
18+
const all = await ctx.applySessionQueryFilter(sessions, { tokens: ['needle'], queryScope: 'all', contentScanLimit: 1 });
19+
assert.equal(all.searchInfo.complete, false); assert.equal(all.searchInfo.scanned, 1);
20+
ctx.setSessionListCache('query', result); assert.equal(ctx.getSessionListCache('query'), result); assert.equal(ctx.getSessionListCache('query').searchInfo, result.searchInfo);
21+
assert.equal(Object.keys(result).includes('searchInfo'), false); assert.ok(Array.isArray(result)); assert.equal(JSON.parse(JSON.stringify(result)).length, 2);
22+
ctx.listAllSessions = async () => result; const browse = await ctx.listSessionBrowse(); assert.equal(browse.searchInfo, result.searchInfo);
23+
const empty = await ctx.applySessionQueryFilter(result, { tokens: [] }); assert.equal(empty.searchInfo, null); assert.equal(empty.length, 2);
24+
ctx.setSessionListCache('empty', empty); assert.equal(ctx.getSessionListCache('empty').searchInfo, null);
25+
assert.equal(ctx.getSessionListCache('query', true), null);
26+
console.log('通过:真实函数 VM 隔离;OpenCode/Codex/Gemini 导出;JSONL 实扫计数;早停完成性;摘要免扫描;缓存与浏览元数据;空查询及数组兼容。');
27+
})().catch(error => { console.error(error); process.exitCode = 1; });

‎tests/unit/web-ui-behavior-parity.test.mjs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,9 @@ test('captured bundled app skeleton only exposes expected data key drift versus
374374
'toolConfigPermissionSaving',
375375
'toolConfigPermissions',
376376
'promptsHint',
377+
'sessionExportRange',
378+
'sessionExportNotice',
379+
'sessionSearchInfo',
377380
'promptsSubTab',
378381
'__skipNextPromptsSubTabLoad',
379382
'promptPresetNameDraft',
@@ -429,6 +432,9 @@ test('captured bundled app skeleton only exposes expected data key drift versus
429432
'showEditClaudeConfigKey',
430433
'showEditProviderKey',
431434
'promptsHint',
435+
'sessionExportRange',
436+
'sessionExportNotice',
437+
'sessionSearchInfo',
432438
'promptsSubTab',
433439
'__skipNextPromptsSubTabLoad',
434440
'promptPresetNameDraft',

0 commit comments

Comments
 (0)