coding-tool-x 3.8.0 → 3.8.2
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.
- package/CHANGELOG.md +2 -0
- package/README.md +4 -6
- package/dist/web/assets/{Analytics-8dRv1aYW.js → Analytics-COEUXmd4.js} +1 -1
- package/dist/web/assets/{ConfigTemplates-BQRXSICO.js → ConfigTemplates-DLLkS53H.js} +1 -1
- package/dist/web/assets/{Home-BtzWhKIa.js → Home-DCEyLzwX.js} +1 -1
- package/dist/web/assets/{PluginManager-BTa-Ygf6.js → PluginManager-Oe1ZHoMO.js} +1 -1
- package/dist/web/assets/{ProjectList-F_cB8ldA.js → ProjectList-CWjuAFof.js} +1 -1
- package/dist/web/assets/{SessionList-LOrbPQvu.js → SessionList-v3ehGg9U.js} +1 -1
- package/dist/web/assets/{SkillManager-BzI_t-yV.js → SkillManager-DUF_qP5S.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-Db9RGiz7.js → WorkspaceManager-CjAWBosO.js} +1 -1
- package/dist/web/assets/index-D5hyHPj7.js +2 -0
- package/dist/web/index.html +1 -1
- package/docs/superpowers/plans/2026-08-10-codex-conversion-compatibility.md +251 -0
- package/docs/superpowers/plans/2026-08-11-windows-command-window-hiding.md +90 -0
- package/docs/superpowers/specs/2026-08-10-codex-conversion-compatibility-design.md +61 -0
- package/docs/superpowers/specs/2026-08-11-windows-command-window-hiding-design.md +44 -0
- package/package.json +2 -2
- package/src/commands/update.js +1 -1
- package/src/config/paths.js +1 -0
- package/src/server/api/codex-sessions.js +14 -14
- package/src/server/api/gemini-sessions.js +52 -14
- package/src/server/api/omp-projects.js +2 -2
- package/src/server/api/omp-sessions.js +61 -23
- package/src/server/api/sessions.js +44 -2
- package/src/server/opencode-proxy-server.js +55 -1037
- package/src/server/services/claude-wire.js +694 -0
- package/src/server/services/codex-sessions.js +41 -233
- package/src/server/services/codex-wire.js +219 -0
- package/src/server/services/dashboard-snapshot-worker.js +5 -5
- package/src/server/services/gemini-sessions.js +67 -157
- package/src/server/services/gemini-wire.js +759 -0
- package/src/server/services/model-detector.js +49 -131
- package/src/server/services/omp-channels.js +23 -4
- package/src/server/services/omp-gateway.js +29 -1
- package/src/server/services/omp-sessions.js +102 -105
- package/src/server/services/omp-settings-manager.js +27 -3
- package/src/server/services/opencode-gateway-adapters.js +22 -1147
- package/src/server/services/opencode-normalization.js +546 -0
- package/src/server/services/opencode-sessions.js +339 -546
- package/src/server/services/session-converter.js +4 -4
- package/src/server/services/session-history-adapters/claude.js +235 -0
- package/src/server/services/session-history-adapters/codex.js +158 -0
- package/src/server/services/session-history-adapters/gemini.js +292 -0
- package/src/server/services/session-history-adapters/index.js +20 -0
- package/src/server/services/session-history-adapters/omp.js +256 -0
- package/src/server/services/session-history-index.js +930 -0
- package/src/server/services/session-history-worker.js +99 -0
- package/src/server/services/session-snapshot-worker.js +7 -7
- package/src/server/services/sessions.js +167 -775
- package/src/server/services/speed-test.js +85 -303
- package/src/server/services/sqlite-connection.js +78 -0
- package/src/server/services/workspace-service.js +3 -3
- package/dist/web/assets/index-DIWJmyL6.js +0 -2
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { HOME_DIR } = require('../../../config/paths');
|
|
7
|
+
const { getGeminiDir } = require('../gemini-config');
|
|
8
|
+
|
|
9
|
+
const HASH_RE = /^[a-f0-9]{64}$/;
|
|
10
|
+
const SESSION_FILE_RE = /^session-(.*)-([a-f0-9]+)\.(json|jsonl)$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract text content from Gemini message parts.
|
|
14
|
+
*/
|
|
15
|
+
function extractContentText(value) {
|
|
16
|
+
if (typeof value === 'string') return value;
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
return value
|
|
19
|
+
.map(item => {
|
|
20
|
+
if (typeof item === 'string') return item;
|
|
21
|
+
if (item && item.text) return item.text;
|
|
22
|
+
return '';
|
|
23
|
+
})
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.join('\n');
|
|
26
|
+
}
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load storage-name → project root mapping from projects.json.
|
|
32
|
+
*/
|
|
33
|
+
function loadProjectRootsByStorageName() {
|
|
34
|
+
const projectsPath = path.join(getGeminiDir(), 'projects.json');
|
|
35
|
+
try {
|
|
36
|
+
const content = fs.readFileSync(projectsPath, 'utf8');
|
|
37
|
+
const parsed = JSON.parse(content);
|
|
38
|
+
const projects = parsed && typeof parsed.projects === 'object' ? parsed.projects : {};
|
|
39
|
+
return new Map(
|
|
40
|
+
Object.entries(projects)
|
|
41
|
+
.filter(([, storageName]) => typeof storageName === 'string')
|
|
42
|
+
.map(([projectRoot, storageName]) => [storageName, projectRoot])
|
|
43
|
+
);
|
|
44
|
+
} catch {
|
|
45
|
+
return new Map();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compute project hash from path.
|
|
51
|
+
*/
|
|
52
|
+
function getFilePathHash(filePath) {
|
|
53
|
+
return crypto.createHash('sha256').update(filePath).digest('hex');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a Gemini message record.
|
|
58
|
+
*/
|
|
59
|
+
function normalizeMessageRecord(record, provider = null, model = null) {
|
|
60
|
+
const isUser =
|
|
61
|
+
record.type === 'user' ||
|
|
62
|
+
record.role === 'user' ||
|
|
63
|
+
record.author === 'user' ||
|
|
64
|
+
record.author === 'human';
|
|
65
|
+
|
|
66
|
+
const isAssistant =
|
|
67
|
+
record.type === 'assistant' ||
|
|
68
|
+
record.type === 'gemini' ||
|
|
69
|
+
record.role === 'assistant' ||
|
|
70
|
+
record.type === 'ai' ||
|
|
71
|
+
record.author === 'ai' ||
|
|
72
|
+
record.author === 'assistant';
|
|
73
|
+
|
|
74
|
+
let role = 'unknown';
|
|
75
|
+
if (isUser) role = 'user';
|
|
76
|
+
else if (isAssistant) role = 'assistant';
|
|
77
|
+
else if (record.type === 'system' || record.role === 'system') role = 'system';
|
|
78
|
+
else role = record.type || record.role || 'unknown';
|
|
79
|
+
|
|
80
|
+
const content = extractContentText(record.content || record.message || record.parts || '');
|
|
81
|
+
const subtype = record.subtype || null;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
messageId: record.id || record.messageId || null,
|
|
85
|
+
role,
|
|
86
|
+
type: record.type || role,
|
|
87
|
+
subtype,
|
|
88
|
+
content: content || '',
|
|
89
|
+
timestamp: record.timestamp || null,
|
|
90
|
+
model: record.model || model || null,
|
|
91
|
+
provider: record.provider || provider || null,
|
|
92
|
+
userMessageNumber: null, // filled later
|
|
93
|
+
usage: record.tokens || record.usage || null,
|
|
94
|
+
extraJson: null
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse a Gemini session file (supports both JSON and JSONL formats).
|
|
100
|
+
* @param {string} filePath
|
|
101
|
+
* @param {string} sessionId
|
|
102
|
+
* @returns {{ messages: Array, meta: object }}
|
|
103
|
+
*/
|
|
104
|
+
function parseSessionContent(filePath, sessionId) {
|
|
105
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
106
|
+
if (!raw.trim()) return { messages: [], meta: { sessionId, messages: [] } };
|
|
107
|
+
|
|
108
|
+
// Try JSON first
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(raw);
|
|
111
|
+
// Handle $set-style JSONL updates: extract only the latest state
|
|
112
|
+
if (parsed.$set) return parseSessionContentFromState(parsed.$set, sessionId);
|
|
113
|
+
if (parsed.messages) return parseSessionContentFromState(parsed, sessionId);
|
|
114
|
+
return { messages: [], meta: { sessionId, messages: [] } };
|
|
115
|
+
} catch (_) {
|
|
116
|
+
// JSONL format: line-by-line $set/message updates
|
|
117
|
+
return parseJsonlContent(raw, sessionId);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseSessionContentFromState(state, sessionId) {
|
|
122
|
+
const meta = {
|
|
123
|
+
sessionId: state.sessionId || state.id || sessionId,
|
|
124
|
+
projectHash: state.projectHash || state.project_hash || null,
|
|
125
|
+
projectPath: state.projectPath || state.project_path || null,
|
|
126
|
+
lastUpdated: state.lastUpdated || state.last_updated || null,
|
|
127
|
+
messages: state.messages || []
|
|
128
|
+
};
|
|
129
|
+
const messages = (state.messages || []).map(m => normalizeMessageRecord(m));
|
|
130
|
+
|
|
131
|
+
let userNum = 0;
|
|
132
|
+
for (const msg of messages) {
|
|
133
|
+
if (msg.role === 'user') {
|
|
134
|
+
userNum++;
|
|
135
|
+
msg.userMessageNumber = userNum;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { messages, meta };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseJsonlContent(raw, sessionId) {
|
|
143
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
144
|
+
const messagesById = new Map();
|
|
145
|
+
const meta = { sessionId, projectHash: null, projectPath: null, lastUpdated: null, messages: [] };
|
|
146
|
+
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
try {
|
|
149
|
+
const record = JSON.parse(line);
|
|
150
|
+
if (record.$set) {
|
|
151
|
+
Object.assign(meta, {
|
|
152
|
+
sessionId: record.$set.sessionId || record.$set.id || meta.sessionId,
|
|
153
|
+
projectHash: record.$set.projectHash || record.$set.project_hash || meta.projectHash,
|
|
154
|
+
projectPath: record.$set.projectPath || record.$set.project_path || meta.projectPath,
|
|
155
|
+
lastUpdated: record.$set.lastUpdated || record.$set.last_updated || meta.lastUpdated
|
|
156
|
+
});
|
|
157
|
+
if (record.$set.messages) meta.messages = record.$set.messages;
|
|
158
|
+
} else if (record.type === 'session') {
|
|
159
|
+
meta.sessionId = record.id || record.sessionId || meta.sessionId;
|
|
160
|
+
meta.projectHash = record.projectHash || record.project_hash || meta.projectHash;
|
|
161
|
+
meta.projectPath = record.projectPath || record.project_path || record.cwd || meta.projectPath;
|
|
162
|
+
meta.lastUpdated = record.lastUpdated || record.last_updated || meta.lastUpdated;
|
|
163
|
+
} else if ((record.sessionId || record.id) && !record.type) {
|
|
164
|
+
meta.sessionId = record.sessionId || record.id || meta.sessionId;
|
|
165
|
+
meta.projectHash = record.projectHash || record.project_hash || meta.projectHash;
|
|
166
|
+
meta.projectPath = record.projectPath || record.project_path || record.cwd || meta.projectPath;
|
|
167
|
+
meta.lastUpdated = record.lastUpdated || record.last_updated || meta.lastUpdated;
|
|
168
|
+
} else if (record.type) {
|
|
169
|
+
const normalized = normalizeMessageRecord(record);
|
|
170
|
+
messagesById.set(normalized.messageId || `line-${messagesById.size}`, normalized);
|
|
171
|
+
}
|
|
172
|
+
} catch (_) {}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (messagesById.size === 0 && meta.messages.length > 0) {
|
|
176
|
+
meta.messages.forEach((message, index) => messagesById.set(message.id || `state-${index}`, normalizeMessageRecord(message)));
|
|
177
|
+
}
|
|
178
|
+
const messages = Array.from(messagesById.values());
|
|
179
|
+
let userNum = 0;
|
|
180
|
+
for (const msg of messages) {
|
|
181
|
+
if (msg.role === 'user') {
|
|
182
|
+
userNum++;
|
|
183
|
+
msg.userMessageNumber = userNum;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { messages, meta };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Inventory all Gemini session files.
|
|
191
|
+
* @returns {Promise<Array>}
|
|
192
|
+
*/
|
|
193
|
+
async function inventory() {
|
|
194
|
+
const descriptors = [];
|
|
195
|
+
const tmpDir = path.join(getGeminiDir(), 'tmp');
|
|
196
|
+
|
|
197
|
+
if (!fs.existsSync(tmpDir)) return descriptors;
|
|
198
|
+
|
|
199
|
+
const projectRoots = loadProjectRootsByStorageName();
|
|
200
|
+
let entries;
|
|
201
|
+
try {
|
|
202
|
+
entries = fs.readdirSync(tmpDir, { withFileTypes: true });
|
|
203
|
+
} catch (_) {
|
|
204
|
+
return descriptors;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
if (!entry.isDirectory()) continue;
|
|
209
|
+
const hashDir = path.join(tmpDir, entry.name);
|
|
210
|
+
const chatsDir = path.join(hashDir, 'chats');
|
|
211
|
+
if (!fs.existsSync(chatsDir)) continue;
|
|
212
|
+
|
|
213
|
+
let chatFiles;
|
|
214
|
+
try {
|
|
215
|
+
chatFiles = fs.readdirSync(chatsDir);
|
|
216
|
+
} catch (_) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const f of chatFiles) {
|
|
221
|
+
const match = f.match(SESSION_FILE_RE);
|
|
222
|
+
if (!match) continue;
|
|
223
|
+
const filePath = path.join(chatsDir, f);
|
|
224
|
+
let stat;
|
|
225
|
+
try {
|
|
226
|
+
stat = fs.statSync(filePath);
|
|
227
|
+
} catch (_) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const projectRoot = projectRoots.get(entry.name) || (() => {
|
|
231
|
+
try {
|
|
232
|
+
return fs.readFileSync(path.join(hashDir, '.project_root'), 'utf8').trim() || null;
|
|
233
|
+
} catch (_) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
})();
|
|
237
|
+
|
|
238
|
+
descriptors.push({
|
|
239
|
+
filePath,
|
|
240
|
+
size: stat.size,
|
|
241
|
+
mtimeMs: stat.mtimeMs,
|
|
242
|
+
sessionId: match[1],
|
|
243
|
+
projectName: projectRoot ? getFilePathHash(projectRoot) : entry.name,
|
|
244
|
+
projectRoot
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return descriptors;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Parse a Gemini session file.
|
|
254
|
+
*/
|
|
255
|
+
async function parse(descriptor) {
|
|
256
|
+
const { filePath, size, mtimeMs, sessionId, projectName, projectRoot } = descriptor;
|
|
257
|
+
|
|
258
|
+
const { messages, meta } = parseSessionContent(filePath, sessionId);
|
|
259
|
+
const resolvedSessionId = meta.sessionId || sessionId;
|
|
260
|
+
const resolvedProjectName = meta.projectHash || projectName || 'unknown';
|
|
261
|
+
const resolvedProjectPath = meta.projectPath || projectRoot || null;
|
|
262
|
+
const firstUserMsg = messages.find(m => m.role === 'user' && m.content);
|
|
263
|
+
const usage = messages.reduce((total, message) => {
|
|
264
|
+
const value = message.usage || {};
|
|
265
|
+
total.input += Number(value.input || value.inputTokens || 0);
|
|
266
|
+
total.output += Number(value.output || value.outputTokens || 0);
|
|
267
|
+
total.cached += Number(value.cached || value.cachedTokens || 0);
|
|
268
|
+
total.reasoning += Number(value.reasoning || value.reasoningTokens || 0);
|
|
269
|
+
total.total += Number(value.total || value.totalTokens || 0);
|
|
270
|
+
return total;
|
|
271
|
+
}, { input: 0, output: 0, cached: 0, reasoning: 0, total: 0 });
|
|
272
|
+
const lastMsg = messages[messages.length - 1];
|
|
273
|
+
|
|
274
|
+
const session = {
|
|
275
|
+
sessionId: resolvedSessionId,
|
|
276
|
+
projectName: resolvedProjectName,
|
|
277
|
+
projectDisplayName: resolvedProjectPath ? path.basename(resolvedProjectPath) : `Project ${resolvedProjectName.substring(0, 8)}`,
|
|
278
|
+
projectFullPath: resolvedProjectPath,
|
|
279
|
+
firstMessage: firstUserMsg ? firstUserMsg.content : null,
|
|
280
|
+
gitBranch: null,
|
|
281
|
+
provider: null,
|
|
282
|
+
model: messages.find(m => m.model)?.model || null,
|
|
283
|
+
startedAt: messages[0] ? messages[0].timestamp : null,
|
|
284
|
+
updatedAt: meta.lastUpdated || (lastMsg ? (lastMsg.timestamp || mtimeMs) : mtimeMs),
|
|
285
|
+
usageJson: JSON.stringify(usage),
|
|
286
|
+
extraJson: JSON.stringify({ projectPath: resolvedProjectPath, storageName: path.basename(path.dirname(path.dirname(filePath))) })
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
return { session, messages };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
module.exports = { inventory, parse, extractContentText, normalizeMessageRecord, parseSessionContent };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function optionalAdapter(name) {
|
|
4
|
+
try {
|
|
5
|
+
return require(`./${name}`);
|
|
6
|
+
} catch (_) {
|
|
7
|
+
return {
|
|
8
|
+
inventory: async () => [],
|
|
9
|
+
parse: async () => ({ session: null, messages: [] })
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
claude: optionalAdapter('claude'),
|
|
16
|
+
codex: optionalAdapter('codex'),
|
|
17
|
+
gemini: optionalAdapter('gemini'),
|
|
18
|
+
omp: optionalAdapter('omp')
|
|
19
|
+
// OpenCode uses its own native schema; not indexed via adapters
|
|
20
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { HOME_DIR } = require('../../../config/paths');
|
|
6
|
+
const { getOmpPaths } = require('../omp-config');
|
|
7
|
+
|
|
8
|
+
function getOmpSessionPaths() {
|
|
9
|
+
return getOmpPaths(process.env, { resolveRuntime: false });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function encodeProjectName(cwd = '') {
|
|
13
|
+
return `--${String(cwd || '').replace(/\\/g, '/').replace(/\//g, '--')}--`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getDisplayName(cwd = '') {
|
|
17
|
+
const normalized = String(cwd || '').trim();
|
|
18
|
+
if (!normalized) return 'Unknown';
|
|
19
|
+
return path.basename(normalized) || normalized;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseOmpSessionId(filePath = '') {
|
|
23
|
+
const base = path.basename(filePath, '.jsonl');
|
|
24
|
+
const match = base.match(/([0-9a-f]{8}-[0-9a-f-]{8,})$/i);
|
|
25
|
+
if (match) return match[1];
|
|
26
|
+
return base;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeTextContent(content) {
|
|
30
|
+
if (typeof content === 'string') return content;
|
|
31
|
+
if (Array.isArray(content)) {
|
|
32
|
+
return content.map((item) => {
|
|
33
|
+
if (typeof item === 'string') return item;
|
|
34
|
+
if (item?.type === 'text') return item.text || '';
|
|
35
|
+
if (item?.text) return item.text;
|
|
36
|
+
return '';
|
|
37
|
+
}).filter(Boolean).join('\n');
|
|
38
|
+
}
|
|
39
|
+
if (content && typeof content === 'object') {
|
|
40
|
+
if (typeof content.text === 'string') return content.text;
|
|
41
|
+
if (typeof content.content === 'string') return content.content;
|
|
42
|
+
}
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractMessageText(message = {}) {
|
|
47
|
+
if (typeof message.content === 'string' || Array.isArray(message.content)) {
|
|
48
|
+
return normalizeTextContent(message.content);
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(message.parts)) {
|
|
51
|
+
return normalizeTextContent(message.parts);
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(message.thinking)) {
|
|
54
|
+
return normalizeTextContent(message.thinking);
|
|
55
|
+
}
|
|
56
|
+
if (message.toolCall && typeof message.toolCall === 'object') {
|
|
57
|
+
return JSON.stringify(message.toolCall, null, 2);
|
|
58
|
+
}
|
|
59
|
+
if (message.toolResult && typeof message.toolResult === 'object') {
|
|
60
|
+
return normalizeTextContent(message.toolResult.content || message.toolResult.output || message.toolResult);
|
|
61
|
+
}
|
|
62
|
+
if (message.bashExecution && typeof message.bashExecution === 'object') {
|
|
63
|
+
return normalizeTextContent(message.bashExecution.output || message.bashExecution.command || message.bashExecution);
|
|
64
|
+
}
|
|
65
|
+
if (typeof message.text === 'string') return message.text;
|
|
66
|
+
if (typeof message.output === 'string') return message.output;
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseUsage(usage = {}) {
|
|
71
|
+
const input = Number(usage.input ?? usage.inputTokens ?? usage.input_tokens ?? usage.prompt_tokens ?? usage.promptTokens ?? 0) || 0;
|
|
72
|
+
const output = Number(usage.output ?? usage.outputTokens ?? usage.output_tokens ?? usage.completion_tokens ?? usage.completionTokens ?? 0) || 0;
|
|
73
|
+
const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? usage.cachedTokens ?? usage.cached_tokens ?? usage.cache_read_input_tokens ?? 0) || 0;
|
|
74
|
+
const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? usage.cacheCreation ?? usage.cache_creation ?? usage.cache_creation_input_tokens ?? 0) || 0;
|
|
75
|
+
const reasoning = Number(usage.reasoningTokens ?? usage.reasoning_tokens ?? 0) || 0;
|
|
76
|
+
const total = Number(usage.totalTokens ?? usage.total_tokens ?? (input + output + cacheRead + cacheWrite + reasoning)) || 0;
|
|
77
|
+
const cost = typeof usage.cost === 'number'
|
|
78
|
+
? usage.cost
|
|
79
|
+
: Number(usage.cost?.total ?? usage.cost?.usd ?? 0) || 0;
|
|
80
|
+
return { input, output, cached: cacheRead, cacheRead, cacheWrite, reasoning, total, cost };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readJsonLines(filePath) {
|
|
84
|
+
try {
|
|
85
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
86
|
+
return raw
|
|
87
|
+
.split(/\r?\n/)
|
|
88
|
+
.filter(line => line.trim())
|
|
89
|
+
.map((line) => {
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(line);
|
|
92
|
+
} catch {
|
|
93
|
+
return { type: 'invalid', raw: line };
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function convertOmpEntry(entry = {}, index = 0) {
|
|
102
|
+
if (!entry || entry.type !== 'message') {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const message = entry.message || {};
|
|
106
|
+
const role = message.role || entry.role;
|
|
107
|
+
const assistantLikeRoles = new Set(['assistant', 'tool', 'toolResult', 'tool_result', 'bashExecution', 'bash_execution', 'custom']);
|
|
108
|
+
if (role !== 'user' && !assistantLikeRoles.has(role)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const text = extractMessageText(message);
|
|
112
|
+
const type = role === 'user' ? 'user' : 'assistant';
|
|
113
|
+
const result = {
|
|
114
|
+
id: entry.id || message.id || `msg-${index}`,
|
|
115
|
+
type,
|
|
116
|
+
role,
|
|
117
|
+
content: text || '',
|
|
118
|
+
timestamp: entry.timestamp || message.timestamp || null
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (type === 'assistant') {
|
|
122
|
+
if (role !== 'assistant') {
|
|
123
|
+
result.subtype = role;
|
|
124
|
+
}
|
|
125
|
+
result.provider = entry.provider || message.provider || '';
|
|
126
|
+
result.model = entry.model || message.model || '';
|
|
127
|
+
result.usage = parseUsage(entry.usage || message.usage || {});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Inventory all OMP session files.
|
|
135
|
+
* @returns {Promise<Array>}
|
|
136
|
+
*/
|
|
137
|
+
async function inventory() {
|
|
138
|
+
const descriptors = [];
|
|
139
|
+
let sessionsDir;
|
|
140
|
+
try {
|
|
141
|
+
sessionsDir = getOmpSessionPaths().sessions;
|
|
142
|
+
} catch (_) {
|
|
143
|
+
return descriptors;
|
|
144
|
+
}
|
|
145
|
+
if (!fs.existsSync(sessionsDir)) return descriptors;
|
|
146
|
+
|
|
147
|
+
function walk(dir) {
|
|
148
|
+
let entries;
|
|
149
|
+
try {
|
|
150
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
151
|
+
} catch {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
const fullPath = path.join(dir, entry.name);
|
|
156
|
+
if (entry.isDirectory()) {
|
|
157
|
+
walk(fullPath);
|
|
158
|
+
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
159
|
+
try {
|
|
160
|
+
const stat = fs.statSync(fullPath);
|
|
161
|
+
descriptors.push({
|
|
162
|
+
filePath: fullPath,
|
|
163
|
+
size: stat.size,
|
|
164
|
+
mtimeMs: stat.mtimeMs,
|
|
165
|
+
sessionId: parseOmpSessionId(fullPath),
|
|
166
|
+
projectHint: null // OMP determines project from session content
|
|
167
|
+
});
|
|
168
|
+
} catch (_) {}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
walk(sessionsDir);
|
|
174
|
+
return descriptors;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parse an OMP session file.
|
|
179
|
+
*/
|
|
180
|
+
async function parse(descriptor) {
|
|
181
|
+
const { filePath, size, mtimeMs } = descriptor;
|
|
182
|
+
const entries = readJsonLines(filePath);
|
|
183
|
+
const header = entries.find(entry => entry?.type === 'session') || {};
|
|
184
|
+
const cwd = header.cwd || '';
|
|
185
|
+
const sessionId = header.id || parseOmpSessionId(filePath) || descriptor.sessionId;
|
|
186
|
+
|
|
187
|
+
const parsedMessages = entries.map((entry, idx) => convertOmpEntry(entry, idx)).filter(Boolean);
|
|
188
|
+
|
|
189
|
+
// Find model change
|
|
190
|
+
const modelChange = [...entries].reverse().find(entry => entry?.type === 'model_change');
|
|
191
|
+
const model = modelChange?.modelId || modelChange?.model || parsedMessages.find(m => m.type === 'assistant' && m.model)?.model || null;
|
|
192
|
+
const provider = modelChange?.provider || parsedMessages.find(m => m.type === 'assistant' && m.provider)?.provider || null;
|
|
193
|
+
|
|
194
|
+
// Aggregate usage
|
|
195
|
+
const usage = parsedMessages
|
|
196
|
+
.filter(m => m.type === 'assistant')
|
|
197
|
+
.reduce((acc, m) => {
|
|
198
|
+
const u = parseUsage(m.usage || {});
|
|
199
|
+
acc.input += u.input;
|
|
200
|
+
acc.output += u.output;
|
|
201
|
+
acc.cached += u.cached;
|
|
202
|
+
acc.reasoning += u.reasoning;
|
|
203
|
+
acc.total += u.total;
|
|
204
|
+
acc.cost += u.cost || 0;
|
|
205
|
+
return acc;
|
|
206
|
+
}, { input: 0, output: 0, cached: 0, reasoning: 0, total: 0, cost: 0 });
|
|
207
|
+
|
|
208
|
+
const usageJson = JSON.stringify(usage);
|
|
209
|
+
|
|
210
|
+
// Normalize messages
|
|
211
|
+
const messages = [];
|
|
212
|
+
let userMessageNumber = 0;
|
|
213
|
+
for (const pm of parsedMessages) {
|
|
214
|
+
const msg = {
|
|
215
|
+
messageId: pm.id || null,
|
|
216
|
+
role: pm.type === 'user' ? 'user' : 'assistant',
|
|
217
|
+
type: pm.type,
|
|
218
|
+
subtype: pm.subtype || null,
|
|
219
|
+
content: typeof pm.content === 'string' ? pm.content : '',
|
|
220
|
+
timestamp: pm.timestamp ? (typeof pm.timestamp === 'number' ? pm.timestamp : new Date(pm.timestamp).getTime()) : null,
|
|
221
|
+
model: pm.model || model || null,
|
|
222
|
+
provider: pm.provider || provider || null,
|
|
223
|
+
userMessageNumber: null,
|
|
224
|
+
extraJson: null
|
|
225
|
+
};
|
|
226
|
+
if (msg.role === 'user') {
|
|
227
|
+
userMessageNumber++;
|
|
228
|
+
msg.userMessageNumber = userMessageNumber;
|
|
229
|
+
}
|
|
230
|
+
messages.push(msg);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const firstUserMsg = messages.find(m => m.role === 'user' && m.content);
|
|
234
|
+
const lastMsg = messages[messages.length - 1];
|
|
235
|
+
const projectName = cwd ? encodeProjectName(cwd) : 'unknown';
|
|
236
|
+
const displayName = getDisplayName(cwd);
|
|
237
|
+
|
|
238
|
+
const session = {
|
|
239
|
+
sessionId,
|
|
240
|
+
projectName,
|
|
241
|
+
projectDisplayName: displayName,
|
|
242
|
+
projectFullPath: cwd || null,
|
|
243
|
+
firstMessage: firstUserMsg ? firstUserMsg.content : null,
|
|
244
|
+
gitBranch: header.gitBranch || null,
|
|
245
|
+
provider,
|
|
246
|
+
model,
|
|
247
|
+
startedAt: messages[0] ? messages[0].timestamp : null,
|
|
248
|
+
updatedAt: lastMsg ? (lastMsg.timestamp || mtimeMs) : mtimeMs,
|
|
249
|
+
usageJson,
|
|
250
|
+
extraJson: JSON.stringify({ cwd })
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return { session, messages };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = { inventory, parse, convertOmpEntry, parseUsage, encodeProjectName: encodeProjectName, getDisplayName, readJsonLines, extractMessageText, normalizeTextContent };
|