codemini-cli 0.5.9 → 0.5.11

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.
Files changed (59) hide show
  1. package/OPERATIONS.md +242 -242
  2. package/README.md +588 -489
  3. package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-HgeDi9HJ.js → highlighted-body-OFNGDK62-CANOG7Xg.js} +1 -1
  4. package/codemini-web/dist/assets/{index-C4tKT3v4.js → index-B71xykPM.js} +108 -108
  5. package/codemini-web/dist/assets/index-Dkq1DdDX.css +2 -0
  6. package/codemini-web/dist/assets/mermaid-GHXKKRXX-Z_w7M93P.js +1 -0
  7. package/codemini-web/dist/index.html +23 -23
  8. package/codemini-web/lib/approval-manager.js +32 -32
  9. package/codemini-web/lib/runtime-bridge.js +17 -11
  10. package/codemini-web/server.js +534 -205
  11. package/deployment.md +212 -212
  12. package/package.json +1 -1
  13. package/skills/brainstorm/SKILL.md +77 -72
  14. package/skills/codemini.skills.json +40 -40
  15. package/skills/grill-me/SKILL.md +30 -30
  16. package/skills/superpowers-lite/SKILL.md +82 -82
  17. package/src/cli.js +74 -74
  18. package/src/commands/chat.js +210 -210
  19. package/src/commands/run.js +313 -313
  20. package/src/commands/skill.js +438 -304
  21. package/src/commands/web.js +57 -57
  22. package/src/core/agent-loop.js +980 -980
  23. package/src/core/ast.js +309 -292
  24. package/src/core/chat-runtime.js +6261 -6240
  25. package/src/core/command-evaluator.js +72 -72
  26. package/src/core/command-loader.js +311 -311
  27. package/src/core/command-policy.js +301 -301
  28. package/src/core/command-risk.js +156 -156
  29. package/src/core/config-store.js +289 -287
  30. package/src/core/constants.js +18 -1
  31. package/src/core/context-compact.js +365 -365
  32. package/src/core/default-system-prompt.js +114 -107
  33. package/src/core/dream-audit.js +105 -105
  34. package/src/core/dream-consolidate.js +229 -229
  35. package/src/core/dream-evaluator.js +185 -185
  36. package/src/core/fff-adapter.js +383 -383
  37. package/src/core/memory-store.js +543 -543
  38. package/src/core/project-index.js +737 -529
  39. package/src/core/project-instructions.js +98 -0
  40. package/src/core/provider/anthropic.js +514 -514
  41. package/src/core/provider/openai-compatible.js +501 -501
  42. package/src/core/reflect-skill.js +178 -178
  43. package/src/core/reply-language.js +40 -40
  44. package/src/core/session-store.js +474 -474
  45. package/src/core/shell-profile.js +237 -237
  46. package/src/core/shell.js +323 -317
  47. package/src/core/soul.js +69 -69
  48. package/src/core/system-prompt-composer.js +52 -42
  49. package/src/core/tool-args.js +199 -154
  50. package/src/core/tool-output.js +184 -184
  51. package/src/core/tool-result-store.js +206 -206
  52. package/src/core/tools.js +3024 -2893
  53. package/src/core/version.js +11 -11
  54. package/src/tui/chat-app.js +5171 -5171
  55. package/src/tui/tool-activity/presenters/misc.js +30 -30
  56. package/src/tui/tool-activity/presenters/system.js +20 -20
  57. package/templates/project-requirements/report-shell.html +582 -582
  58. package/codemini-web/dist/assets/index-BSdIdn3L.css +0 -2
  59. package/codemini-web/dist/assets/mermaid-GHXKKRXX-CDgkkDBg.js +0 -1
@@ -1,474 +1,474 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { getSessionsDir } from './paths.js';
4
- import { normalizePlanState } from './plan-state.js';
5
- import { normalizeTodos } from './todo-state.js';
6
-
7
- const ALLOWED_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
8
- const SESSION_LEGACY_EXT = '.json';
9
- const SESSION_JSONL_EXT = '.jsonl';
10
- const SESSION_INDEX_FILE = 'index.json';
11
- const SESSION_INDEX_VERSION = 1;
12
- const DEFAULT_SESSION_TITLE = '新会话';
13
-
14
- function createSessionId() {
15
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
16
- const rand = Math.random().toString(36).slice(2, 8);
17
- return `${ts}-${rand}`;
18
- }
19
-
20
- function sanitizeToolCall(tc, index) {
21
- const id = String(tc?.id || `tc-${index + 1}`);
22
- const fnName = String(tc?.function?.name || tc?.name || '').trim();
23
- const fnArgsRaw = tc?.function?.arguments ?? tc?.arguments ?? '{}';
24
- const fnArgs = typeof fnArgsRaw === 'string' ? fnArgsRaw : JSON.stringify(fnArgsRaw);
25
- if (!fnName) return null;
26
- const out = {
27
- id,
28
- type: 'function',
29
- function: {
30
- name: fnName,
31
- arguments: fnArgs
32
- }
33
- };
34
- if (Number.isFinite(Number(tc?.durationMs))) out.durationMs = Number(tc.durationMs);
35
- if (typeof tc?.summary === 'string' && tc.summary.trim()) out.summary = tc.summary.trim();
36
- if (typeof tc?.status === 'string' && tc.status.trim()) out.status = tc.status.trim();
37
- return out;
38
- }
39
-
40
- function normalizeWhitespace(value) {
41
- return String(value || '').replace(/\s+/g, ' ').trim();
42
- }
43
-
44
- function stripMarkdown(value) {
45
- return normalizeWhitespace(value)
46
- .replace(/```[\s\S]*?```/g, ' ')
47
- .replace(/`([^`]*)`/g, '$1')
48
- .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
49
- .replace(/^[#>*\-\d.\s]+/g, '')
50
- .trim();
51
- }
52
-
53
- export function deriveSessionTitle(messages = []) {
54
- const firstUser = Array.isArray(messages)
55
- ? messages.find((msg) => msg?.role === 'user' && normalizeWhitespace(msg?.content))
56
- : null;
57
- const text = stripMarkdown(firstUser?.content || '');
58
- if (!text) return DEFAULT_SESSION_TITLE;
59
- return text.length > 48 ? `${text.slice(0, 45).trimEnd()}...` : text;
60
- }
61
-
62
- function sanitizeMessage(msg) {
63
- const role = String(msg?.role || '').trim();
64
- if (!ALLOWED_ROLES.has(role)) return null;
65
- const content =
66
- typeof msg?.content === 'string' || Array.isArray(msg?.content) ? msg.content : String(msg?.content || '');
67
-
68
- const out = {
69
- role,
70
- content
71
- };
72
-
73
- if (typeof msg?.model_content === 'string' && msg.model_content) out.model_content = msg.model_content;
74
- if (msg?.tool_call_id) out.tool_call_id = String(msg.tool_call_id);
75
- if (Number.isFinite(Number(msg?.tool_duration_ms))) out.tool_duration_ms = Number(msg.tool_duration_ms);
76
- if (typeof msg?.tool_summary === 'string' && msg.tool_summary.trim()) out.tool_summary = msg.tool_summary.trim();
77
- if (typeof msg?.tool_status === 'string' && msg.tool_status.trim()) out.tool_status = msg.tool_status.trim();
78
- if (typeof msg?.name === 'string' && msg.name.trim()) out.name = msg.name.trim();
79
- if (typeof msg?.at === 'string' && msg.at.trim()) out.at = msg.at;
80
- if (typeof msg?.reasoning_content === 'string' && msg.reasoning_content) {
81
- out.reasoning_content = msg.reasoning_content;
82
- }
83
- if (Array.isArray(msg?.reasoning_details) && msg.reasoning_details.length > 0) {
84
- out.reasoning_details = msg.reasoning_details
85
- .filter((detail) => detail && typeof detail === 'object')
86
- .map((detail) => ({ ...detail }));
87
- }
88
-
89
- if (Array.isArray(msg?.tool_calls)) {
90
- const toolCalls = msg.tool_calls.map(sanitizeToolCall).filter(Boolean);
91
- if (toolCalls.length > 0) out.tool_calls = toolCalls;
92
- }
93
-
94
- if (Array.isArray(msg?.plan_transcript)) {
95
- out.plan_transcript = msg.plan_transcript
96
- .filter((entry) => entry && typeof entry === 'object')
97
- .map((entry) => ({
98
- ...entry,
99
- segments: Array.isArray(entry.segments) ? entry.segments : []
100
- }));
101
- }
102
-
103
- return out;
104
- }
105
-
106
- function sanitizeSession(session, fallbackId = '') {
107
- const id = String(session?.id || fallbackId || '').trim();
108
- if (!id) throw new Error('Session id is required');
109
- const now = new Date().toISOString();
110
- const createdAt = String(session?.createdAt || now);
111
- const updatedAt = String(session?.updatedAt || now);
112
- const messages = Array.isArray(session?.messages) ? session.messages.map(sanitizeMessage).filter(Boolean) : [];
113
- const compactView = Array.isArray(session?.compact?.view)
114
- ? session.compact.view.map(sanitizeMessage).filter(Boolean)
115
- : [];
116
-
117
- const out = {
118
- id,
119
- createdAt,
120
- updatedAt,
121
- title: normalizeWhitespace(session?.title) || deriveSessionTitle(messages),
122
- messages
123
- };
124
-
125
- if (typeof session?.projectDir === 'string' && session.projectDir.trim()) {
126
- out.projectDir = session.projectDir.trim();
127
- }
128
- if (session?.model) out.model = String(session.model);
129
- if (session?.mode) out.mode = String(session.mode);
130
- const normalizedPlan = normalizePlanState(session?.planState);
131
- if (normalizedPlan) out.planState = normalizedPlan;
132
-
133
- const todos = normalizeTodos(session?.todos);
134
- if (todos.length > 0) out.todos = todos;
135
-
136
- if (compactView.length > 0) {
137
- out.compact = {
138
- view: compactView,
139
- timestamp: typeof session?.compact?.timestamp === 'string' && session.compact.timestamp.trim()
140
- ? session.compact.timestamp
141
- : now
142
- };
143
- if (Number.isFinite(Number(session?.compact?.boundaryIndex))) {
144
- out.compact.boundaryIndex = Number(session.compact.boundaryIndex);
145
- }
146
- if (typeof session?.compact?.mode === 'string' && session.compact.mode.trim()) {
147
- out.compact.mode = session.compact.mode.trim();
148
- }
149
- }
150
-
151
- return out;
152
- }
153
-
154
- function sessionPathById(sessionId, ext = SESSION_JSONL_EXT) {
155
- return path.join(getSessionsDir(), `${sessionId}${ext}`);
156
- }
157
-
158
- function sessionIndexPath() {
159
- return path.join(getSessionsDir(), SESSION_INDEX_FILE);
160
- }
161
-
162
- function isSafeSessionId(sessionId) {
163
- return /^[A-Za-z0-9_.-]+$/.test(String(sessionId || ''));
164
- }
165
-
166
- function sessionIdFromFileName(fileName) {
167
- if (fileName.endsWith(SESSION_JSONL_EXT)) return fileName.slice(0, -SESSION_JSONL_EXT.length);
168
- if (fileName.endsWith(SESSION_LEGACY_EXT)) return fileName.slice(0, -SESSION_LEGACY_EXT.length);
169
- return '';
170
- }
171
-
172
- async function listSessionFiles() {
173
- const dir = getSessionsDir();
174
- await fs.mkdir(dir, { recursive: true });
175
- const entries = await fs.readdir(dir, { withFileTypes: true });
176
- return entries
177
- .filter((e) => e.isFile() && (e.name.endsWith(SESSION_JSONL_EXT) || e.name.endsWith(SESSION_LEGACY_EXT)))
178
- .map((e) => path.join(dir, e.name));
179
- }
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
-
210
- function summarizeParsedSession(parsed, filePath) {
211
- const id = parsed.id || sessionIdFromFileName(path.basename(filePath));
212
- const updatedAt = parsed.updatedAt || parsed.createdAt || '';
213
- const latestMessage = Array.isArray(parsed.messages) ? parsed.messages.at(-1) : null;
214
- const preview = latestMessage?.content ? String(latestMessage.content).replace(/\s+/g, ' ').slice(0, 80) : '';
215
- const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
216
- return {
217
- id,
218
- title: normalizeWhitespace(parsed.title) || deriveSessionTitle(messages),
219
- updatedAt,
220
- messageCount: Array.isArray(parsed.messages) ? parsed.messages.length : 0,
221
- preview,
222
- projectDir: typeof parsed.projectDir === 'string' ? parsed.projectDir : '',
223
- model: typeof parsed.model === 'string' ? parsed.model : '',
224
- mode: typeof parsed.mode === 'string' ? parsed.mode : ''
225
- };
226
- }
227
-
228
- async function tryReadJson(filePath) {
229
- const raw = await fs.readFile(filePath, 'utf8');
230
- return JSON.parse(raw);
231
- }
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
-
315
- async function loadLatestJsonlObject(filePath) {
316
- const raw = await fs.readFile(filePath, 'utf8');
317
- const lines = String(raw || '')
318
- .split('\n')
319
- .map((line) => line.trim())
320
- .filter(Boolean);
321
- for (let i = lines.length - 1; i >= 0; i -= 1) {
322
- try {
323
- return JSON.parse(lines[i]);
324
- } catch {
325
- continue;
326
- }
327
- }
328
- throw new Error(`No valid JSONL record found: ${filePath}`);
329
- }
330
-
331
- async function loadSessionPayload(sessionId) {
332
- const jsonlPath = sessionPathById(sessionId, SESSION_JSONL_EXT);
333
- let jsonlError = null;
334
- try {
335
- return await loadLatestJsonlObject(jsonlPath);
336
- } catch (error) {
337
- if (error?.code !== 'ENOENT') jsonlError = error;
338
- }
339
- const legacyPath = sessionPathById(sessionId, SESSION_LEGACY_EXT);
340
- try {
341
- return await tryReadJson(legacyPath);
342
- } catch (error) {
343
- if (jsonlError) throw jsonlError;
344
- throw error;
345
- }
346
- }
347
-
348
- export async function createSession(projectDir = process.cwd()) {
349
- const sessionId = createSessionId();
350
- const dir = getSessionsDir();
351
- await fs.mkdir(dir, { recursive: true });
352
- const filePath = sessionPathById(sessionId, SESSION_JSONL_EXT);
353
- const payload = {
354
- id: sessionId,
355
- createdAt: new Date().toISOString(),
356
- updatedAt: new Date().toISOString(),
357
- title: DEFAULT_SESSION_TITLE,
358
- projectDir: String(projectDir || process.cwd()),
359
- messages: []
360
- };
361
- await fs.writeFile(filePath, `${JSON.stringify(payload)}\n`, 'utf8');
362
- await upsertSessionIndexEntry(payload, filePath);
363
- return payload;
364
- }
365
-
366
- export async function loadSession(sessionId) {
367
- const parsed = await loadSessionPayload(sessionId);
368
- return sanitizeSession(parsed, sessionId);
369
- }
370
-
371
- export async function saveSession(session) {
372
- const dir = getSessionsDir();
373
- await fs.mkdir(dir, { recursive: true });
374
- const normalized = sanitizeSession(session);
375
- normalized.updatedAt = new Date().toISOString();
376
- const filePath = sessionPathById(normalized.id, SESSION_JSONL_EXT);
377
- await fs.appendFile(filePath, `${JSON.stringify(normalized)}\n`, 'utf8');
378
- await upsertSessionIndexEntry(normalized, filePath);
379
- }
380
-
381
- export async function resolveSession(sessionId) {
382
- if (sessionId) {
383
- return loadSession(sessionId);
384
- }
385
- return createSession();
386
- }
387
-
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);
393
- }
394
-
395
- export async function deleteSession(sessionId) {
396
- const id = String(sessionId || '').trim();
397
- if (!id || !isSafeSessionId(id)) {
398
- throw new Error('Invalid session id');
399
- }
400
-
401
- const files = await listSessionFiles();
402
- const targets = new Set();
403
- for (const file of files) {
404
- const fileId = sessionIdFromFileName(path.basename(file));
405
- if (fileId === id) {
406
- targets.add(file);
407
- continue;
408
- }
409
- try {
410
- const parsed = file.endsWith(SESSION_JSONL_EXT) ? await loadLatestJsonlObject(file) : await tryReadJson(file);
411
- if (String(parsed?.id || '').trim() === id) targets.add(file);
412
- } catch {}
413
- }
414
-
415
- let removed = 0;
416
- const fallbackTargets = [
417
- sessionPathById(id, SESSION_JSONL_EXT),
418
- sessionPathById(id, SESSION_LEGACY_EXT)
419
- ];
420
- for (const file of [...targets, ...fallbackTargets]) {
421
- try {
422
- await fs.unlink(file);
423
- removed += 1;
424
- } catch (error) {
425
- if (error?.code !== 'ENOENT') throw error;
426
- }
427
- }
428
- if (removed > 0) {
429
- try {
430
- await rebuildSessionIndex();
431
- } catch {}
432
- }
433
- return { removed };
434
- }
435
-
436
- export async function pruneSessions(policy = {}) {
437
- const maxSessions = Number(policy.max_sessions || 100);
438
- const retentionDays = Number(policy.retention_days || 30);
439
- const all = await listSessions(10000);
440
- const now = Date.now();
441
- const expireMs = retentionDays > 0 ? retentionDays * 24 * 60 * 60 * 1000 : 0;
442
- const keepIds = new Set();
443
-
444
- const sorted = [...all].sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
445
- for (let i = 0; i < sorted.length; i += 1) {
446
- const s = sorted[i];
447
- if (i >= maxSessions) continue;
448
- if (expireMs > 0 && s.updatedAt) {
449
- const t = Date.parse(s.updatedAt);
450
- if (!Number.isNaN(t) && now - t > expireMs) continue;
451
- }
452
- keepIds.add(s.id);
453
- }
454
-
455
- const dir = getSessionsDir();
456
- const entries = await fs.readdir(dir, { withFileTypes: true });
457
- let removed = 0;
458
- for (const e of entries) {
459
- if (!e.isFile()) continue;
460
- const id = sessionIdFromFileName(e.name);
461
- if (!id) continue;
462
- if (keepIds.has(id)) continue;
463
- try {
464
- await fs.unlink(path.join(dir, e.name));
465
- removed += 1;
466
- } catch {
467
- continue;
468
- }
469
- }
470
- try {
471
- await rebuildSessionIndex();
472
- } catch {}
473
- return { removed, kept: keepIds.size };
474
- }
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getSessionsDir } from './paths.js';
4
+ import { normalizePlanState } from './plan-state.js';
5
+ import { normalizeTodos } from './todo-state.js';
6
+
7
+ const ALLOWED_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
8
+ const SESSION_LEGACY_EXT = '.json';
9
+ const SESSION_JSONL_EXT = '.jsonl';
10
+ const SESSION_INDEX_FILE = 'index.json';
11
+ const SESSION_INDEX_VERSION = 1;
12
+ const DEFAULT_SESSION_TITLE = '新会话';
13
+
14
+ function createSessionId() {
15
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
16
+ const rand = Math.random().toString(36).slice(2, 8);
17
+ return `${ts}-${rand}`;
18
+ }
19
+
20
+ function sanitizeToolCall(tc, index) {
21
+ const id = String(tc?.id || `tc-${index + 1}`);
22
+ const fnName = String(tc?.function?.name || tc?.name || '').trim();
23
+ const fnArgsRaw = tc?.function?.arguments ?? tc?.arguments ?? '{}';
24
+ const fnArgs = typeof fnArgsRaw === 'string' ? fnArgsRaw : JSON.stringify(fnArgsRaw);
25
+ if (!fnName) return null;
26
+ const out = {
27
+ id,
28
+ type: 'function',
29
+ function: {
30
+ name: fnName,
31
+ arguments: fnArgs
32
+ }
33
+ };
34
+ if (Number.isFinite(Number(tc?.durationMs))) out.durationMs = Number(tc.durationMs);
35
+ if (typeof tc?.summary === 'string' && tc.summary.trim()) out.summary = tc.summary.trim();
36
+ if (typeof tc?.status === 'string' && tc.status.trim()) out.status = tc.status.trim();
37
+ return out;
38
+ }
39
+
40
+ function normalizeWhitespace(value) {
41
+ return String(value || '').replace(/\s+/g, ' ').trim();
42
+ }
43
+
44
+ function stripMarkdown(value) {
45
+ return normalizeWhitespace(value)
46
+ .replace(/```[\s\S]*?```/g, ' ')
47
+ .replace(/`([^`]*)`/g, '$1')
48
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
49
+ .replace(/^[#>*\-\d.\s]+/g, '')
50
+ .trim();
51
+ }
52
+
53
+ export function deriveSessionTitle(messages = []) {
54
+ const firstUser = Array.isArray(messages)
55
+ ? messages.find((msg) => msg?.role === 'user' && normalizeWhitespace(msg?.content))
56
+ : null;
57
+ const text = stripMarkdown(firstUser?.content || '');
58
+ if (!text) return DEFAULT_SESSION_TITLE;
59
+ return text.length > 48 ? `${text.slice(0, 45).trimEnd()}...` : text;
60
+ }
61
+
62
+ function sanitizeMessage(msg) {
63
+ const role = String(msg?.role || '').trim();
64
+ if (!ALLOWED_ROLES.has(role)) return null;
65
+ const content =
66
+ typeof msg?.content === 'string' || Array.isArray(msg?.content) ? msg.content : String(msg?.content || '');
67
+
68
+ const out = {
69
+ role,
70
+ content
71
+ };
72
+
73
+ if (typeof msg?.model_content === 'string' && msg.model_content) out.model_content = msg.model_content;
74
+ if (msg?.tool_call_id) out.tool_call_id = String(msg.tool_call_id);
75
+ if (Number.isFinite(Number(msg?.tool_duration_ms))) out.tool_duration_ms = Number(msg.tool_duration_ms);
76
+ if (typeof msg?.tool_summary === 'string' && msg.tool_summary.trim()) out.tool_summary = msg.tool_summary.trim();
77
+ if (typeof msg?.tool_status === 'string' && msg.tool_status.trim()) out.tool_status = msg.tool_status.trim();
78
+ if (typeof msg?.name === 'string' && msg.name.trim()) out.name = msg.name.trim();
79
+ if (typeof msg?.at === 'string' && msg.at.trim()) out.at = msg.at;
80
+ if (typeof msg?.reasoning_content === 'string' && msg.reasoning_content) {
81
+ out.reasoning_content = msg.reasoning_content;
82
+ }
83
+ if (Array.isArray(msg?.reasoning_details) && msg.reasoning_details.length > 0) {
84
+ out.reasoning_details = msg.reasoning_details
85
+ .filter((detail) => detail && typeof detail === 'object')
86
+ .map((detail) => ({ ...detail }));
87
+ }
88
+
89
+ if (Array.isArray(msg?.tool_calls)) {
90
+ const toolCalls = msg.tool_calls.map(sanitizeToolCall).filter(Boolean);
91
+ if (toolCalls.length > 0) out.tool_calls = toolCalls;
92
+ }
93
+
94
+ if (Array.isArray(msg?.plan_transcript)) {
95
+ out.plan_transcript = msg.plan_transcript
96
+ .filter((entry) => entry && typeof entry === 'object')
97
+ .map((entry) => ({
98
+ ...entry,
99
+ segments: Array.isArray(entry.segments) ? entry.segments : []
100
+ }));
101
+ }
102
+
103
+ return out;
104
+ }
105
+
106
+ function sanitizeSession(session, fallbackId = '') {
107
+ const id = String(session?.id || fallbackId || '').trim();
108
+ if (!id) throw new Error('Session id is required');
109
+ const now = new Date().toISOString();
110
+ const createdAt = String(session?.createdAt || now);
111
+ const updatedAt = String(session?.updatedAt || now);
112
+ const messages = Array.isArray(session?.messages) ? session.messages.map(sanitizeMessage).filter(Boolean) : [];
113
+ const compactView = Array.isArray(session?.compact?.view)
114
+ ? session.compact.view.map(sanitizeMessage).filter(Boolean)
115
+ : [];
116
+
117
+ const out = {
118
+ id,
119
+ createdAt,
120
+ updatedAt,
121
+ title: normalizeWhitespace(session?.title) || deriveSessionTitle(messages),
122
+ messages
123
+ };
124
+
125
+ if (typeof session?.projectDir === 'string' && session.projectDir.trim()) {
126
+ out.projectDir = session.projectDir.trim();
127
+ }
128
+ if (session?.model) out.model = String(session.model);
129
+ if (session?.mode) out.mode = String(session.mode);
130
+ const normalizedPlan = normalizePlanState(session?.planState);
131
+ if (normalizedPlan) out.planState = normalizedPlan;
132
+
133
+ const todos = normalizeTodos(session?.todos);
134
+ if (todos.length > 0) out.todos = todos;
135
+
136
+ if (compactView.length > 0) {
137
+ out.compact = {
138
+ view: compactView,
139
+ timestamp: typeof session?.compact?.timestamp === 'string' && session.compact.timestamp.trim()
140
+ ? session.compact.timestamp
141
+ : now
142
+ };
143
+ if (Number.isFinite(Number(session?.compact?.boundaryIndex))) {
144
+ out.compact.boundaryIndex = Number(session.compact.boundaryIndex);
145
+ }
146
+ if (typeof session?.compact?.mode === 'string' && session.compact.mode.trim()) {
147
+ out.compact.mode = session.compact.mode.trim();
148
+ }
149
+ }
150
+
151
+ return out;
152
+ }
153
+
154
+ function sessionPathById(sessionId, ext = SESSION_JSONL_EXT) {
155
+ return path.join(getSessionsDir(), `${sessionId}${ext}`);
156
+ }
157
+
158
+ function sessionIndexPath() {
159
+ return path.join(getSessionsDir(), SESSION_INDEX_FILE);
160
+ }
161
+
162
+ function isSafeSessionId(sessionId) {
163
+ return /^[A-Za-z0-9_.-]+$/.test(String(sessionId || ''));
164
+ }
165
+
166
+ function sessionIdFromFileName(fileName) {
167
+ if (fileName.endsWith(SESSION_JSONL_EXT)) return fileName.slice(0, -SESSION_JSONL_EXT.length);
168
+ if (fileName.endsWith(SESSION_LEGACY_EXT)) return fileName.slice(0, -SESSION_LEGACY_EXT.length);
169
+ return '';
170
+ }
171
+
172
+ async function listSessionFiles() {
173
+ const dir = getSessionsDir();
174
+ await fs.mkdir(dir, { recursive: true });
175
+ const entries = await fs.readdir(dir, { withFileTypes: true });
176
+ return entries
177
+ .filter((e) => e.isFile() && (e.name.endsWith(SESSION_JSONL_EXT) || e.name.endsWith(SESSION_LEGACY_EXT)))
178
+ .map((e) => path.join(dir, e.name));
179
+ }
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
+
210
+ function summarizeParsedSession(parsed, filePath) {
211
+ const id = parsed.id || sessionIdFromFileName(path.basename(filePath));
212
+ const updatedAt = parsed.updatedAt || parsed.createdAt || '';
213
+ const latestMessage = Array.isArray(parsed.messages) ? parsed.messages.at(-1) : null;
214
+ const preview = latestMessage?.content ? String(latestMessage.content).replace(/\s+/g, ' ').slice(0, 80) : '';
215
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
216
+ return {
217
+ id,
218
+ title: normalizeWhitespace(parsed.title) || deriveSessionTitle(messages),
219
+ updatedAt,
220
+ messageCount: Array.isArray(parsed.messages) ? parsed.messages.length : 0,
221
+ preview,
222
+ projectDir: typeof parsed.projectDir === 'string' ? parsed.projectDir : '',
223
+ model: typeof parsed.model === 'string' ? parsed.model : '',
224
+ mode: typeof parsed.mode === 'string' ? parsed.mode : ''
225
+ };
226
+ }
227
+
228
+ async function tryReadJson(filePath) {
229
+ const raw = await fs.readFile(filePath, 'utf8');
230
+ return JSON.parse(raw);
231
+ }
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
+
315
+ async function loadLatestJsonlObject(filePath) {
316
+ const raw = await fs.readFile(filePath, 'utf8');
317
+ const lines = String(raw || '')
318
+ .split('\n')
319
+ .map((line) => line.trim())
320
+ .filter(Boolean);
321
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
322
+ try {
323
+ return JSON.parse(lines[i]);
324
+ } catch {
325
+ continue;
326
+ }
327
+ }
328
+ throw new Error(`No valid JSONL record found: ${filePath}`);
329
+ }
330
+
331
+ async function loadSessionPayload(sessionId) {
332
+ const jsonlPath = sessionPathById(sessionId, SESSION_JSONL_EXT);
333
+ let jsonlError = null;
334
+ try {
335
+ return await loadLatestJsonlObject(jsonlPath);
336
+ } catch (error) {
337
+ if (error?.code !== 'ENOENT') jsonlError = error;
338
+ }
339
+ const legacyPath = sessionPathById(sessionId, SESSION_LEGACY_EXT);
340
+ try {
341
+ return await tryReadJson(legacyPath);
342
+ } catch (error) {
343
+ if (jsonlError) throw jsonlError;
344
+ throw error;
345
+ }
346
+ }
347
+
348
+ export async function createSession(projectDir = process.cwd()) {
349
+ const sessionId = createSessionId();
350
+ const dir = getSessionsDir();
351
+ await fs.mkdir(dir, { recursive: true });
352
+ const filePath = sessionPathById(sessionId, SESSION_JSONL_EXT);
353
+ const payload = {
354
+ id: sessionId,
355
+ createdAt: new Date().toISOString(),
356
+ updatedAt: new Date().toISOString(),
357
+ title: DEFAULT_SESSION_TITLE,
358
+ projectDir: String(projectDir || process.cwd()),
359
+ messages: []
360
+ };
361
+ await fs.writeFile(filePath, `${JSON.stringify(payload)}\n`, 'utf8');
362
+ await upsertSessionIndexEntry(payload, filePath);
363
+ return payload;
364
+ }
365
+
366
+ export async function loadSession(sessionId) {
367
+ const parsed = await loadSessionPayload(sessionId);
368
+ return sanitizeSession(parsed, sessionId);
369
+ }
370
+
371
+ export async function saveSession(session) {
372
+ const dir = getSessionsDir();
373
+ await fs.mkdir(dir, { recursive: true });
374
+ const normalized = sanitizeSession(session);
375
+ normalized.updatedAt = new Date().toISOString();
376
+ const filePath = sessionPathById(normalized.id, SESSION_JSONL_EXT);
377
+ await fs.appendFile(filePath, `${JSON.stringify(normalized)}\n`, 'utf8');
378
+ await upsertSessionIndexEntry(normalized, filePath);
379
+ }
380
+
381
+ export async function resolveSession(sessionId) {
382
+ if (sessionId) {
383
+ return loadSession(sessionId);
384
+ }
385
+ return createSession();
386
+ }
387
+
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);
393
+ }
394
+
395
+ export async function deleteSession(sessionId) {
396
+ const id = String(sessionId || '').trim();
397
+ if (!id || !isSafeSessionId(id)) {
398
+ throw new Error('Invalid session id');
399
+ }
400
+
401
+ const files = await listSessionFiles();
402
+ const targets = new Set();
403
+ for (const file of files) {
404
+ const fileId = sessionIdFromFileName(path.basename(file));
405
+ if (fileId === id) {
406
+ targets.add(file);
407
+ continue;
408
+ }
409
+ try {
410
+ const parsed = file.endsWith(SESSION_JSONL_EXT) ? await loadLatestJsonlObject(file) : await tryReadJson(file);
411
+ if (String(parsed?.id || '').trim() === id) targets.add(file);
412
+ } catch {}
413
+ }
414
+
415
+ let removed = 0;
416
+ const fallbackTargets = [
417
+ sessionPathById(id, SESSION_JSONL_EXT),
418
+ sessionPathById(id, SESSION_LEGACY_EXT)
419
+ ];
420
+ for (const file of [...targets, ...fallbackTargets]) {
421
+ try {
422
+ await fs.unlink(file);
423
+ removed += 1;
424
+ } catch (error) {
425
+ if (error?.code !== 'ENOENT') throw error;
426
+ }
427
+ }
428
+ if (removed > 0) {
429
+ try {
430
+ await rebuildSessionIndex();
431
+ } catch {}
432
+ }
433
+ return { removed };
434
+ }
435
+
436
+ export async function pruneSessions(policy = {}) {
437
+ const maxSessions = Number(policy.max_sessions || 100);
438
+ const retentionDays = Number(policy.retention_days || 30);
439
+ const all = await listSessions(10000);
440
+ const now = Date.now();
441
+ const expireMs = retentionDays > 0 ? retentionDays * 24 * 60 * 60 * 1000 : 0;
442
+ const keepIds = new Set();
443
+
444
+ const sorted = [...all].sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
445
+ for (let i = 0; i < sorted.length; i += 1) {
446
+ const s = sorted[i];
447
+ if (i >= maxSessions) continue;
448
+ if (expireMs > 0 && s.updatedAt) {
449
+ const t = Date.parse(s.updatedAt);
450
+ if (!Number.isNaN(t) && now - t > expireMs) continue;
451
+ }
452
+ keepIds.add(s.id);
453
+ }
454
+
455
+ const dir = getSessionsDir();
456
+ const entries = await fs.readdir(dir, { withFileTypes: true });
457
+ let removed = 0;
458
+ for (const e of entries) {
459
+ if (!e.isFile()) continue;
460
+ const id = sessionIdFromFileName(e.name);
461
+ if (!id) continue;
462
+ if (keepIds.has(id)) continue;
463
+ try {
464
+ await fs.unlink(path.join(dir, e.name));
465
+ removed += 1;
466
+ } catch {
467
+ continue;
468
+ }
469
+ }
470
+ try {
471
+ await rebuildSessionIndex();
472
+ } catch {}
473
+ return { removed, kept: keepIds.size };
474
+ }