codemini-cli 0.5.5 → 0.5.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codemini-cli",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "Coding CLI optimized for small-model workflows and Windows PowerShell",
5
5
  "keywords": [
6
6
  "cli",
@@ -29,7 +29,10 @@
29
29
  "test": "node --test tests/*.test.js",
30
30
  "build:web": "npm install --prefix codemini-web && npm run build --prefix codemini-web",
31
31
  "prepack": "npm run build:web",
32
- "pack:offline": "npm pack"
32
+ "pack:offline": "npm pack",
33
+ "bump:patch": "npm version patch --no-git-tag-version",
34
+ "bump:minor": "npm version minor --no-git-tag-version",
35
+ "bump:major": "npm version major --no-git-tag-version"
33
36
  },
34
37
  "files": [
35
38
  "bin",
@@ -37,6 +40,7 @@
37
40
  "codemini-web/server.js",
38
41
  "codemini-web/lib",
39
42
  "codemini-web/dist",
43
+ "codemini-web/codemini_logo.png",
40
44
  "souls",
41
45
  "templates",
42
46
  "skills",
package/src/cli.js CHANGED
@@ -4,9 +4,7 @@ import { handleConfig } from './commands/config.js';
4
4
  import { handleDoctor } from './commands/doctor.js';
5
5
  import { handleSkill } from './commands/skill.js';
6
6
  import { handleWeb } from './commands/web.js';
7
- import pkg from '../package.json' with { type: 'json' };
8
-
9
- const VERSION = pkg.version;
7
+ import { VERSION } from './core/version.js';
10
8
 
11
9
  function printHelp() {
12
10
  console.log(`codemini ${VERSION}
@@ -4,7 +4,7 @@ import { loadConfig } from '../core/config-store.js';
4
4
  import { createChatRuntime } from '../core/chat-runtime.js';
5
5
  import { buildDefaultSystemPrompt } from '../core/default-system-prompt.js';
6
6
  import { resolveSession } from '../core/session-store.js';
7
- import pkg from '../../package.json' with { type: 'json' };
7
+ import { VERSION } from '../core/version.js';
8
8
 
9
9
  function parseChatArgs(args) {
10
10
  const parsed = {
@@ -175,7 +175,7 @@ export async function handleChat(args) {
175
175
  language: config.ui?.language || 'zh',
176
176
  shellName: config.shell?.default || 'powershell',
177
177
  safeMode: config.policy?.safe_mode !== false,
178
- version: pkg.version
178
+ version: VERSION
179
179
  })
180
180
  );
181
181
 
@@ -21,7 +21,9 @@ import {
21
21
  compactMessagesLocally,
22
22
  estimateMessagesTokens,
23
23
  microCompactMessages,
24
- parseCompactArgs
24
+ parseCompactArgs,
25
+ buildTranscriptForLLM,
26
+ COMPACT_SUMMARY_PROMPT
25
27
  } from './context-compact.js';
26
28
  import { getReplyLanguage, getReplyLanguageName } from './reply-language.js';
27
29
  import { composeSystemPrompt } from './system-prompt-composer.js';
@@ -2401,6 +2403,33 @@ async function generateSessionTitle({ userText, assistantText = '', config, sign
2401
2403
  }
2402
2404
  }
2403
2405
 
2406
+ function createCompactSummaryGenerator(config, signal) {
2407
+ return async (olderMessages) => {
2408
+ const latestConfig = await loadConfig().catch(() => config);
2409
+ const effectiveConfig = latestConfig || config;
2410
+ const fastModel = resolveFastModel(effectiveConfig);
2411
+ if (!fastModel) throw new Error('No fast model');
2412
+ const transcript = buildTranscriptForLLM(olderMessages);
2413
+ const result = await createChatCompletion({
2414
+ sdkProvider: effectiveConfig.sdk?.provider,
2415
+ baseUrl: effectiveConfig.gateway.base_url,
2416
+ apiKey: effectiveConfig.gateway.api_key,
2417
+ model: fastModel,
2418
+ messages: [
2419
+ { role: 'system', content: COMPACT_SUMMARY_PROMPT },
2420
+ { role: 'user', content: transcript.slice(0, 12000) }
2421
+ ],
2422
+ tools: [],
2423
+ timeoutMs: Math.min(Number(effectiveConfig.gateway?.timeout_ms || 30000), 60000),
2424
+ maxRetries: 0,
2425
+ signal
2426
+ });
2427
+ const text = result?.text?.trim();
2428
+ if (!text) throw new Error('Empty summary');
2429
+ return text;
2430
+ };
2431
+ }
2432
+
2404
2433
  function estimatePromptTokensForRequest(sessionMessages, userText = '') {
2405
2434
  const tokenMsgs = [
2406
2435
  ...(Array.isArray(sessionMessages) ? sessionMessages : []),
@@ -2641,9 +2670,10 @@ async function askModel({
2641
2670
  if (needsMacro) {
2642
2671
  const sourceIsCompacted = Boolean(compacted);
2643
2672
  const macroSource = compacted ?? session.messages;
2644
- const auto = compactMessagesLocally(macroSource, {
2673
+ const auto = await compactMessagesLocally(macroSource, {
2645
2674
  mode: preflightPct >= hardPct ? 'aggressive' : 'conservative',
2646
- force: true
2675
+ force: true,
2676
+ generateSummary: createCompactSummaryGenerator(config, signal)
2647
2677
  });
2648
2678
  if (auto.changed) {
2649
2679
  compacted = auto.compacted.map((m) => ({ ...m, at: new Date().toISOString() }));
@@ -2720,7 +2750,7 @@ async function askModel({
2720
2750
  compacted.push({ ...userMessage });
2721
2751
  if (onCompactedUpdate) onCompactedUpdate(compacted);
2722
2752
  }
2723
- if (!shouldGenerateTitle) {
2753
+ if (shouldReplaceSessionTitle(session.title)) {
2724
2754
  session.title = deriveSessionTitle(session.messages);
2725
2755
  }
2726
2756
  session.model = model || config.model.name;
@@ -2969,18 +2999,26 @@ async function askModel({
2969
2999
  }
2970
3000
  }
2971
3001
  }
3002
+ session.model = model || config.model.name;
3003
+ session.mode = executionMode || config.execution?.mode || 'normal';
3004
+ await flushScheduledSave();
3005
+ await saveSession(session);
3006
+ // Generate a better title asynchronously after saving
2972
3007
  if (shouldReplaceSessionTitle(session.title)) {
2973
- session.title = await generateSessionTitle({
3008
+ const titleSessionId = session.id;
3009
+ generateSessionTitle({
2974
3010
  userText: text,
2975
3011
  assistantText: loopResult.text || '',
2976
3012
  config,
2977
3013
  signal
2978
- });
3014
+ }).then(async (generatedTitle) => {
3015
+ if (generatedTitle && generatedTitle !== session.title) {
3016
+ session.title = generatedTitle;
3017
+ await saveSession(session);
3018
+ onTitleUpdateCallback?.(titleSessionId, generatedTitle);
3019
+ }
3020
+ }).catch(() => {});
2979
3021
  }
2980
- session.model = model || config.model.name;
2981
- session.mode = executionMode || config.execution?.mode || 'normal';
2982
- await flushScheduledSave();
2983
- await saveSession(session);
2984
3022
  try {
2985
3023
  await pruneSessions(config.sessions || {});
2986
3024
  } catch {
@@ -4160,6 +4198,7 @@ export async function createChatRuntime({
4160
4198
  session.projectDir = process.cwd();
4161
4199
  }
4162
4200
  let activeRequestToolApproval = typeof requestToolApproval === 'function' ? requestToolApproval : null;
4201
+ let onTitleUpdateCallback = null;
4163
4202
  const startupEvents = [];
4164
4203
  const initialIndex = await initializeProjectIndex(process.cwd()).catch(() => null);
4165
4204
  if (initialIndex?.summary) {
@@ -4729,16 +4768,24 @@ export async function createChatRuntime({
4729
4768
  if (assistantText) {
4730
4769
  appendSessionMessage(stampedMessage('assistant', assistantText, extra));
4731
4770
  }
4771
+ currentSession.model = model || config.model.name;
4772
+ currentSession.mode = executionMode || config.execution?.mode || 'normal';
4773
+ await saveSession(currentSession);
4774
+ // Generate a better title asynchronously after saving
4732
4775
  if (shouldReplaceSessionTitle(currentSession.title)) {
4733
- currentSession.title = await generateSessionTitle({
4776
+ const titleSessionId = currentSession.id;
4777
+ generateSessionTitle({
4734
4778
  userText,
4735
4779
  assistantText,
4736
4780
  config
4737
- });
4781
+ }).then(async (generatedTitle) => {
4782
+ if (generatedTitle && generatedTitle !== currentSession.title) {
4783
+ currentSession.title = generatedTitle;
4784
+ await saveSession(currentSession);
4785
+ onTitleUpdateCallback?.(titleSessionId, generatedTitle);
4786
+ }
4787
+ }).catch(() => {});
4738
4788
  }
4739
- currentSession.model = model || config.model.name;
4740
- currentSession.mode = executionMode || config.execution?.mode || 'normal';
4741
- await saveSession(currentSession);
4742
4789
  };
4743
4790
 
4744
4791
  const persistUserExchange = async (userText) => {
@@ -5720,7 +5767,7 @@ export async function createChatRuntime({
5720
5767
 
5721
5768
  const sourceIsCompacted = Boolean(compactedForModel);
5722
5769
  const macroSource = compactedForModel ?? currentSession.messages;
5723
- const result = compactMessagesLocally(macroSource, { mode: compactState.mode, force: true });
5770
+ const result = await compactMessagesLocally(macroSource, { mode: compactState.mode, force: true, generateSummary: createCompactSummaryGenerator(config, null) });
5724
5771
  if (!result.changed) {
5725
5772
  return { type: 'system', text: 'Nothing to compact yet' };
5726
5773
  }
@@ -5957,9 +6004,10 @@ export async function createChatRuntime({
5957
6004
  if (needsMacro) {
5958
6005
  const sourceIsCompacted = Boolean(compactedForModel);
5959
6006
  const macroSource = compactedForModel ?? currentSession.messages;
5960
- const autoResult = compactMessagesLocally(macroSource, {
6007
+ const autoResult = await compactMessagesLocally(macroSource, {
5961
6008
  mode: compactState.mode,
5962
- force: true
6009
+ force: true,
6010
+ generateSummary: createCompactSummaryGenerator(config, null)
5963
6011
  });
5964
6012
  if (autoResult.changed) {
5965
6013
  setCompactedView(
@@ -6102,6 +6150,9 @@ export async function createChatRuntime({
6102
6150
  activeRequestToolApproval = typeof handler === 'function' ? handler : null;
6103
6151
  return true;
6104
6152
  },
6153
+ setOnTitleUpdate: (cb) => {
6154
+ onTitleUpdateCallback = typeof cb === 'function' ? cb : null;
6155
+ },
6105
6156
  dispose: async () => {
6106
6157
  if (typeof disposeTools === 'function') {
6107
6158
  await disposeTools();
@@ -39,6 +39,103 @@ function modeToKeepRecent(mode) {
39
39
  return 6;
40
40
  }
41
41
 
42
+ function getToolCallId(call) {
43
+ return String(call?.id || '').trim();
44
+ }
45
+
46
+ function getMessageToolCallIds(message) {
47
+ if (!Array.isArray(message?.tool_calls)) return [];
48
+ return message.tool_calls.map(getToolCallId).filter(Boolean);
49
+ }
50
+
51
+ function toolResultNote(message) {
52
+ const text = textFromContent(message?.content);
53
+ let parsed;
54
+ try { parsed = JSON.parse(text); } catch { parsed = null; }
55
+ const summary = parsed && typeof parsed === 'object'
56
+ ? summarizeToolResult(parsed)
57
+ : text.replace(/\s+/g, ' ').trim();
58
+ const clipped = summary.length > 600 ? `${summary.slice(0, 597)}...` : summary;
59
+ return `[Compacted orphan tool result]\n${clipped || 'No content'}`;
60
+ }
61
+
62
+ function expandRecentStartToToolBoundary(messages, start) {
63
+ let adjusted = Math.max(0, Math.min(start, messages.length));
64
+ while (adjusted > 0 && messages[adjusted]?.role === 'tool') {
65
+ adjusted -= 1;
66
+ }
67
+ if (
68
+ adjusted > 0 &&
69
+ messages[adjusted]?.role !== 'assistant' &&
70
+ messages[adjusted + 1]?.role === 'tool'
71
+ ) {
72
+ adjusted += 1;
73
+ }
74
+ return adjusted;
75
+ }
76
+
77
+ function sanitizeRecentMessagesForModel(messages) {
78
+ const out = [];
79
+ let activeAssistantIndex = -1;
80
+ let expectedToolIds = new Set();
81
+ let matchedToolIds = new Set();
82
+
83
+ const finalizeActiveAssistant = () => {
84
+ if (activeAssistantIndex < 0) return;
85
+ const assistant = out[activeAssistantIndex];
86
+ if (!Array.isArray(assistant?.tool_calls)) {
87
+ activeAssistantIndex = -1;
88
+ expectedToolIds = new Set();
89
+ matchedToolIds = new Set();
90
+ return;
91
+ }
92
+ const toolCalls = assistant.tool_calls.filter((call) => matchedToolIds.has(getToolCallId(call)));
93
+ if (toolCalls.length > 0) {
94
+ out[activeAssistantIndex] = { ...assistant, tool_calls: toolCalls };
95
+ } else {
96
+ const { tool_calls, ...rest } = assistant;
97
+ out[activeAssistantIndex] = rest;
98
+ }
99
+ activeAssistantIndex = -1;
100
+ expectedToolIds = new Set();
101
+ matchedToolIds = new Set();
102
+ };
103
+
104
+ for (const message of messages) {
105
+ if (!message || typeof message !== 'object') continue;
106
+ if (message.role === 'assistant') {
107
+ finalizeActiveAssistant();
108
+ const clone = { ...message };
109
+ out.push(clone);
110
+ const ids = getMessageToolCallIds(clone);
111
+ if (ids.length > 0) {
112
+ activeAssistantIndex = out.length - 1;
113
+ expectedToolIds = new Set(ids);
114
+ matchedToolIds = new Set();
115
+ }
116
+ continue;
117
+ }
118
+
119
+ if (message.role === 'tool') {
120
+ const id = String(message.tool_call_id || '').trim();
121
+ if (id && expectedToolIds.has(id)) {
122
+ out.push({ ...message });
123
+ matchedToolIds.add(id);
124
+ continue;
125
+ }
126
+ finalizeActiveAssistant();
127
+ out.push({ role: 'assistant', content: toolResultNote(message), at: message.at });
128
+ continue;
129
+ }
130
+
131
+ finalizeActiveAssistant();
132
+ out.push({ ...message });
133
+ }
134
+
135
+ finalizeActiveAssistant();
136
+ return out;
137
+ }
138
+
42
139
  function buildLocalSummary(messages) {
43
140
  const goal = [];
44
141
  const constraints = [];
@@ -103,6 +200,50 @@ function buildLocalSummary(messages) {
103
200
  return lines.join('\n').trim();
104
201
  }
105
202
 
203
+ /**
204
+ * Build a conversation transcript from messages for LLM summarization input.
205
+ * Includes structured metadata (tool calls, file changes) alongside the text.
206
+ */
207
+ export function buildTranscriptForLLM(messages) {
208
+ const parts = [];
209
+ for (const msg of messages) {
210
+ const text = textFromContent(msg.content).replace(/\s+/g, ' ').trim();
211
+ if (!text && !Array.isArray(msg.tool_calls) && msg.role !== 'user') continue;
212
+ if (msg.role === 'user') {
213
+ parts.push(`[User]\n${text.slice(0, 600)}`);
214
+ } else if (msg.role === 'assistant') {
215
+ let block = `[Assistant]\n${text.slice(0, 600)}`;
216
+ if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
217
+ const toolNames = msg.tool_calls.map(tc => tc.function?.name || tc.name || 'tool').join(', ');
218
+ block += `\n[Called tools: ${toolNames}]`;
219
+ }
220
+ parts.push(block);
221
+ } else if (msg.role === 'tool') {
222
+ let parsed;
223
+ try { parsed = JSON.parse(text); } catch { parsed = null; }
224
+ if (parsed && typeof parsed === 'object') {
225
+ const summary = summarizeToolResult(parsed);
226
+ parts.push(`[Tool Result]\n${summary.slice(0, 400)}`);
227
+ } else {
228
+ parts.push(`[Tool Result]\n${text.slice(0, 300)}`);
229
+ }
230
+ }
231
+ }
232
+ return parts.join('\n\n');
233
+ }
234
+
235
+ export const COMPACT_SUMMARY_PROMPT = `Summarize the following conversation into a structured context summary that preserves all critical information for continuing the task. Be thorough and specific.
236
+
237
+ Include:
238
+ - The user's goal and requirements
239
+ - Key decisions made and reasoning
240
+ - Files that were read, modified, or created (with paths)
241
+ - Current progress and what remains
242
+ - Any errors encountered and how they were resolved
243
+ - Important constraints or conventions discovered
244
+
245
+ Write in the same language as the conversation. Be concise but do not omit important details.`;
246
+
106
247
  /**
107
248
  * Micro-compact: in-place clearing of old tool result content.
108
249
  * Does NOT change message count or order — only replaces tool result text
@@ -151,7 +292,7 @@ export function microCompactMessages(messages, { keepRecent = 5, enabled = true
151
292
  return { messages: result, changed: true, tokensSaved };
152
293
  }
153
294
 
154
- export function compactMessagesLocally(messages, { mode = 'default', force = false } = {}) {
295
+ export async function compactMessagesLocally(messages, { mode = 'default', force = false, generateSummary = null } = {}) {
155
296
  const keepRecent = modeToKeepRecent(mode);
156
297
  if (!Array.isArray(messages) || messages.length <= 1) {
157
298
  return {
@@ -167,12 +308,23 @@ export function compactMessagesLocally(messages, { mode = 'default', force = fal
167
308
  };
168
309
  }
169
310
 
170
- const older = messages.slice(0, Math.max(0, messages.length - keepRecent));
171
- const recent = messages.slice(Math.max(0, messages.length - keepRecent));
172
- const summary = buildLocalSummary(older);
173
- const compacted = [{ role: 'assistant', content: summary }, ...recent];
311
+ const recentStart = expandRecentStartToToolBoundary(messages, Math.max(0, messages.length - keepRecent));
312
+ const older = messages.slice(0, recentStart);
313
+ const recent = sanitizeRecentMessagesForModel(messages.slice(recentStart));
314
+
315
+ let summary;
316
+ if (typeof generateSummary === 'function') {
317
+ try {
318
+ summary = await generateSummary(older);
319
+ } catch {
320
+ summary = buildLocalSummary(older);
321
+ }
322
+ } else {
323
+ summary = buildLocalSummary(older);
324
+ }
174
325
 
175
- const boundaryIndex = Math.max(0, messages.length - keepRecent);
326
+ const compacted = [{ role: 'assistant', content: summary }, ...recent];
327
+ const boundaryIndex = recentStart;
176
328
 
177
329
  return {
178
330
  compacted,
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { LANGUAGE_FILE_TYPES } from './constants.js';
3
+ import { getPackageInfo } from './version.js';
3
4
 
4
5
  const DEFAULT_COMMAND = 'fff-mcp';
5
6
  const DEFAULT_TIMEOUT_MS = 15_000;
@@ -109,10 +110,7 @@ class FffMcpClient {
109
110
  await this.sendRequest('initialize', {
110
111
  protocolVersion: '2024-11-05',
111
112
  capabilities: {},
112
- clientInfo: {
113
- name: 'codemini-cli',
114
- version: '0.5.5'
115
- }
113
+ clientInfo: getPackageInfo()
116
114
  });
117
115
  this.sendNotification('notifications/initialized', {});
118
116
  }
@@ -383,4 +381,3 @@ export function createFffAdapter({ workspaceRoot, config }) {
383
381
  }
384
382
  };
385
383
  }
386
-
@@ -7,6 +7,8 @@ import { normalizeTodos } from './todo-state.js';
7
7
  const ALLOWED_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
8
8
  const SESSION_LEGACY_EXT = '.json';
9
9
  const SESSION_JSONL_EXT = '.jsonl';
10
+ const SESSION_INDEX_FILE = 'index.json';
11
+ const SESSION_INDEX_VERSION = 1;
10
12
  const DEFAULT_SESSION_TITLE = '新会话';
11
13
 
12
14
  function createSessionId() {
@@ -153,6 +155,10 @@ function sessionPathById(sessionId, ext = SESSION_JSONL_EXT) {
153
155
  return path.join(getSessionsDir(), `${sessionId}${ext}`);
154
156
  }
155
157
 
158
+ function sessionIndexPath() {
159
+ return path.join(getSessionsDir(), SESSION_INDEX_FILE);
160
+ }
161
+
156
162
  function isSafeSessionId(sessionId) {
157
163
  return /^[A-Za-z0-9_.-]+$/.test(String(sessionId || ''));
158
164
  }
@@ -172,6 +178,35 @@ async function listSessionFiles() {
172
178
  .map((e) => path.join(dir, e.name));
173
179
  }
174
180
 
181
+ async function listSessionFileMeta() {
182
+ const files = await listSessionFiles();
183
+ const meta = [];
184
+ for (const file of files) {
185
+ try {
186
+ const stat = await fs.stat(file);
187
+ meta.push({
188
+ name: path.basename(file),
189
+ size: stat.size,
190
+ mtimeMs: Math.trunc(stat.mtimeMs)
191
+ });
192
+ } catch {
193
+ continue;
194
+ }
195
+ }
196
+ meta.sort((a, b) => a.name.localeCompare(b.name));
197
+ return meta;
198
+ }
199
+
200
+ function sameSessionFileMeta(a = [], b = []) {
201
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
202
+ for (let i = 0; i < a.length; i += 1) {
203
+ if (a[i]?.name !== b[i]?.name) return false;
204
+ if (Number(a[i]?.size || 0) !== Number(b[i]?.size || 0)) return false;
205
+ if (Number(a[i]?.mtimeMs || 0) !== Number(b[i]?.mtimeMs || 0)) return false;
206
+ }
207
+ return true;
208
+ }
209
+
175
210
  function summarizeParsedSession(parsed, filePath) {
176
211
  const id = parsed.id || sessionIdFromFileName(path.basename(filePath));
177
212
  const updatedAt = parsed.updatedAt || parsed.createdAt || '';
@@ -195,6 +230,88 @@ async function tryReadJson(filePath) {
195
230
  return JSON.parse(raw);
196
231
  }
197
232
 
233
+ async function readSessionIndex() {
234
+ try {
235
+ const index = await tryReadJson(sessionIndexPath());
236
+ if (index?.version !== SESSION_INDEX_VERSION || !Array.isArray(index?.sessions) || !Array.isArray(index?.files)) {
237
+ return null;
238
+ }
239
+ return index;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ async function writeSessionIndex(index) {
246
+ const dir = getSessionsDir();
247
+ await fs.mkdir(dir, { recursive: true });
248
+ const filePath = sessionIndexPath();
249
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
250
+ const payload = {
251
+ version: SESSION_INDEX_VERSION,
252
+ updatedAt: new Date().toISOString(),
253
+ files: Array.isArray(index?.files) ? index.files : [],
254
+ sessions: Array.isArray(index?.sessions) ? index.sessions : []
255
+ };
256
+ await fs.writeFile(tempPath, `${JSON.stringify(payload)}\n`, 'utf8');
257
+ await fs.rename(tempPath, filePath);
258
+ }
259
+
260
+ async function rebuildSessionIndex(fileMeta = null) {
261
+ const files = await listSessionFiles();
262
+ const sessionsById = new Map();
263
+ for (const file of files) {
264
+ try {
265
+ const parsed = file.endsWith(SESSION_JSONL_EXT) ? await loadLatestJsonlObject(file) : await tryReadJson(file);
266
+ const summary = summarizeParsedSession(parsed, file);
267
+ if (!summary.id) continue;
268
+ const existing = sessionsById.get(summary.id);
269
+ if (!existing || String(summary.updatedAt) > String(existing.updatedAt)) {
270
+ sessionsById.set(summary.id, summary);
271
+ }
272
+ } catch {
273
+ continue;
274
+ }
275
+ }
276
+
277
+ const sessions = Array.from(sessionsById.values());
278
+ sessions.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
279
+ const filesMeta = fileMeta || await listSessionFileMeta();
280
+ const index = { files: filesMeta, sessions };
281
+ await writeSessionIndex(index);
282
+ return { ...index, version: SESSION_INDEX_VERSION };
283
+ }
284
+
285
+ async function getSessionIndex() {
286
+ const fileMeta = await listSessionFileMeta();
287
+ const index = await readSessionIndex();
288
+ if (index && sameSessionFileMeta(index.files, fileMeta)) return index;
289
+ return rebuildSessionIndex(fileMeta);
290
+ }
291
+
292
+ async function upsertSessionIndexEntry(session, filePath) {
293
+ try {
294
+ const summary = summarizeParsedSession(session, filePath);
295
+ if (!summary.id) return;
296
+ const stat = await fs.stat(filePath);
297
+ const fileEntry = {
298
+ name: path.basename(filePath),
299
+ size: stat.size,
300
+ mtimeMs: Math.trunc(stat.mtimeMs)
301
+ };
302
+ const index = await readSessionIndex();
303
+ const files = Array.isArray(index?.files) ? index.files.filter((entry) => entry?.name !== fileEntry.name) : [];
304
+ files.push(fileEntry);
305
+ files.sort((a, b) => a.name.localeCompare(b.name));
306
+ const sessions = Array.isArray(index?.sessions) ? index.sessions.filter((entry) => entry?.id !== summary.id) : [];
307
+ sessions.push(summary);
308
+ sessions.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
309
+ await writeSessionIndex({ files, sessions });
310
+ } catch {
311
+ // Index updates are an optimization; session data remains authoritative.
312
+ }
313
+ }
314
+
198
315
  async function loadLatestJsonlObject(filePath) {
199
316
  const raw = await fs.readFile(filePath, 'utf8');
200
317
  const lines = String(raw || '')
@@ -242,6 +359,7 @@ export async function createSession(projectDir = process.cwd()) {
242
359
  messages: []
243
360
  };
244
361
  await fs.writeFile(filePath, `${JSON.stringify(payload)}\n`, 'utf8');
362
+ await upsertSessionIndexEntry(payload, filePath);
245
363
  return payload;
246
364
  }
247
365
 
@@ -257,6 +375,7 @@ export async function saveSession(session) {
257
375
  normalized.updatedAt = new Date().toISOString();
258
376
  const filePath = sessionPathById(normalized.id, SESSION_JSONL_EXT);
259
377
  await fs.appendFile(filePath, `${JSON.stringify(normalized)}\n`, 'utf8');
378
+ await upsertSessionIndexEntry(normalized, filePath);
260
379
  }
261
380
 
262
381
  export async function resolveSession(sessionId) {
@@ -266,27 +385,11 @@ export async function resolveSession(sessionId) {
266
385
  return createSession();
267
386
  }
268
387
 
269
- export async function listSessions(limit = 30) {
270
- const files = await listSessionFiles();
271
-
272
- const sessionsById = new Map();
273
- for (const file of files) {
274
- try {
275
- const parsed = file.endsWith(SESSION_JSONL_EXT) ? await loadLatestJsonlObject(file) : await tryReadJson(file);
276
- const summary = summarizeParsedSession(parsed, file);
277
- if (!summary.id) continue;
278
- const existing = sessionsById.get(summary.id);
279
- if (!existing || String(summary.updatedAt) > String(existing.updatedAt)) {
280
- sessionsById.set(summary.id, summary);
281
- }
282
- } catch {
283
- continue;
284
- }
285
- }
286
-
287
- const sessions = Array.from(sessionsById.values());
288
- sessions.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
289
- return sessions.filter((s) => Number(s.messageCount || 0) > 0).slice(0, limit);
388
+ export async function listSessions(limit = 30, { includeEmpty = false } = {}) {
389
+ const index = await getSessionIndex();
390
+ return [...index.sessions]
391
+ .filter((s) => includeEmpty || Number(s.messageCount || 0) > 0)
392
+ .slice(0, limit);
290
393
  }
291
394
 
292
395
  export async function deleteSession(sessionId) {
@@ -322,6 +425,11 @@ export async function deleteSession(sessionId) {
322
425
  if (error?.code !== 'ENOENT') throw error;
323
426
  }
324
427
  }
428
+ if (removed > 0) {
429
+ try {
430
+ await rebuildSessionIndex();
431
+ } catch {}
432
+ }
325
433
  return { removed };
326
434
  }
327
435
 
@@ -359,5 +467,8 @@ export async function pruneSessions(policy = {}) {
359
467
  continue;
360
468
  }
361
469
  }
470
+ try {
471
+ await rebuildSessionIndex();
472
+ } catch {}
362
473
  return { removed, kept: keepIds.size };
363
474
  }
@@ -0,0 +1,11 @@
1
+ import pkg from '../../package.json' with { type: 'json' };
2
+
3
+ export const PACKAGE_NAME = pkg.name || 'codemini-cli';
4
+ export const VERSION = pkg.version;
5
+
6
+ export function getPackageInfo() {
7
+ return {
8
+ name: PACKAGE_NAME,
9
+ version: VERSION
10
+ };
11
+ }