coding-tool-x 3.8.0 → 3.8.1

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,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 };