codemini-cli 0.5.10 → 0.5.12
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/OPERATIONS.md +242 -242
- package/README.md +588 -588
- package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-7HL7yft8.js → highlighted-body-OFNGDK62-B-G99D0A.js} +1 -1
- package/codemini-web/dist/assets/{index-BK75hMb2.js → index-DIGUEzan.js} +108 -108
- package/codemini-web/dist/assets/index-Dkq1DdDX.css +2 -0
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-va2Kl89u.js +1 -0
- package/codemini-web/dist/index.html +35 -23
- package/codemini-web/lib/approval-manager.js +32 -32
- package/codemini-web/lib/runtime-bridge.js +17 -11
- package/codemini-web/server.js +534 -205
- package/deployment.md +212 -212
- package/package.json +2 -2
- package/skills/brainstorm/SKILL.md +77 -77
- package/skills/codemini.skills.json +40 -40
- package/skills/grill-me/SKILL.md +30 -30
- package/skills/superpowers-lite/SKILL.md +82 -82
- package/src/cli.js +74 -74
- package/src/commands/chat.js +210 -210
- package/src/commands/run.js +313 -313
- package/src/commands/skill.js +438 -304
- package/src/commands/web.js +57 -57
- package/src/core/agent-loop.js +980 -980
- package/src/core/ast.js +309 -307
- package/src/core/chat-runtime.js +6261 -6253
- package/src/core/command-evaluator.js +72 -72
- package/src/core/command-loader.js +311 -311
- package/src/core/command-policy.js +301 -301
- package/src/core/command-risk.js +156 -156
- package/src/core/config-store.js +286 -285
- package/src/core/constants.js +18 -1
- package/src/core/context-compact.js +365 -365
- package/src/core/default-system-prompt.js +114 -107
- package/src/core/dream-audit.js +105 -105
- package/src/core/dream-consolidate.js +229 -229
- package/src/core/dream-evaluator.js +185 -185
- package/src/core/fff-adapter.js +383 -383
- package/src/core/memory-store.js +543 -543
- package/src/core/project-index.js +737 -548
- package/src/core/project-instructions.js +98 -98
- package/src/core/provider/anthropic.js +514 -514
- package/src/core/provider/openai-compatible.js +501 -501
- package/src/core/reflect-skill.js +178 -178
- package/src/core/reply-language.js +40 -40
- package/src/core/session-store.js +474 -474
- package/src/core/shell-profile.js +237 -237
- package/src/core/shell.js +323 -323
- package/src/core/soul.js +69 -69
- package/src/core/system-prompt-composer.js +52 -52
- package/src/core/tool-args.js +199 -154
- package/src/core/tool-output.js +184 -184
- package/src/core/tool-result-store.js +206 -206
- package/src/core/tools.js +3024 -2893
- package/src/core/version.js +11 -11
- package/src/tui/chat-app.js +5173 -5171
- package/src/tui/tool-activity/presenters/misc.js +30 -30
- package/src/tui/tool-activity/presenters/system.js +20 -20
- package/templates/project-requirements/report-shell.html +582 -582
- package/codemini-web/dist/assets/index-BSdIdn3L.css +0 -2
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-Dg9qh8mg.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
|
+
}
|