mini-coder 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -48
- package/assets/icon-1-minimal.svg +31 -0
- package/assets/icon-2-dark-terminal.svg +48 -0
- package/assets/icon-3-gradient-modern.svg +45 -0
- package/assets/icon-4-filled-bold.svg +54 -0
- package/assets/icon-5-community-badge.svg +63 -0
- package/assets/preview-0-5-0.png +0 -0
- package/assets/preview.gif +0 -0
- package/bin/mc.ts +14 -0
- package/bun.lock +438 -0
- package/package.json +12 -29
- package/src/agent.ts +640 -0
- package/src/cli.ts +124 -0
- package/src/git.ts +171 -0
- package/src/headless.ts +140 -0
- package/src/index.ts +666 -0
- package/src/input.ts +155 -0
- package/src/paths.ts +37 -0
- package/src/plugins.ts +183 -0
- package/src/prompt.ts +301 -0
- package/src/session.ts +1043 -0
- package/src/settings.ts +191 -0
- package/src/skills.ts +262 -0
- package/src/submit.ts +323 -0
- package/src/theme.ts +147 -0
- package/src/tools.ts +636 -0
- package/src/ui/agent.test.ts +49 -0
- package/src/ui/agent.ts +210 -0
- package/src/ui/commands.test.ts +610 -0
- package/src/ui/commands.ts +638 -0
- package/src/ui/conversation.test.ts +892 -0
- package/src/ui/conversation.ts +926 -0
- package/src/ui/help.test.ts +44 -0
- package/src/ui/help.ts +125 -0
- package/src/ui/input.test.ts +74 -0
- package/src/ui/input.ts +138 -0
- package/src/ui/overlay.test.ts +42 -0
- package/src/ui/overlay.ts +59 -0
- package/src/ui/status.test.ts +451 -0
- package/src/ui/status.ts +357 -0
- package/src/ui.ts +694 -0
- package/.claude/settings.local.json +0 -54
- package/.prettierignore +0 -7
- package/dist/mc-edit.js +0 -275
- package/dist/mc.js +0 -7355
- package/docs/KNOWN_ISSUES.md +0 -13
- package/docs/design-decisions.md +0 -31
- package/docs/mini-coder.1.md +0 -227
- package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
- package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
- package/lefthook.yml +0 -4
package/src/session.ts
ADDED
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session persistence layer.
|
|
3
|
+
*
|
|
4
|
+
* Stores sessions and their message histories in a single SQLite database
|
|
5
|
+
* via `bun:sqlite`. Messages are stored as JSON-serialized pi-ai {@link Message}
|
|
6
|
+
* objects grouped by turn number. Cumulative token/cost stats are computed
|
|
7
|
+
* from the message history rather than stored separately.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import type {
|
|
14
|
+
AssistantMessage,
|
|
15
|
+
Message,
|
|
16
|
+
ToolResultMessage,
|
|
17
|
+
UserMessage,
|
|
18
|
+
} from "@mariozechner/pi-ai";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A persisted session record.
|
|
26
|
+
*
|
|
27
|
+
* Represents a single conversation scoped to a working directory.
|
|
28
|
+
* The `model` and `effort` fields reflect the values at session creation —
|
|
29
|
+
* the user may switch models mid-session via `/model`, but the session
|
|
30
|
+
* record is not updated (individual assistant messages carry their own model).
|
|
31
|
+
*/
|
|
32
|
+
export interface Session {
|
|
33
|
+
/** Unique session identifier (UUID). */
|
|
34
|
+
id: string;
|
|
35
|
+
/** Working directory the session is scoped to. */
|
|
36
|
+
cwd: string;
|
|
37
|
+
/** Provider/model string at creation time, e.g. `"anthropic/claude-sonnet-4-20250514"`. */
|
|
38
|
+
model: string | null;
|
|
39
|
+
/** Thinking effort level at creation time. */
|
|
40
|
+
effort: string | null;
|
|
41
|
+
/** ID of the session this was forked from, or `null` if original. */
|
|
42
|
+
forkedFrom: string | null;
|
|
43
|
+
/** Unix timestamp in milliseconds when the session was created. */
|
|
44
|
+
createdAt: number;
|
|
45
|
+
/** Unix timestamp in milliseconds, updated on each new message. */
|
|
46
|
+
updatedAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Session row used by the `/session` picker. */
|
|
50
|
+
export interface SessionListEntry extends Session {
|
|
51
|
+
/** First conversational user message collapsed into a single-line preview, or `null` when none exists. */
|
|
52
|
+
firstUserPreview: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Cumulative input/output token and cost statistics for a session.
|
|
57
|
+
*
|
|
58
|
+
* Computed by summing `usage` fields from all {@link AssistantMessage}s
|
|
59
|
+
* in the session's history. Not stored — derived on load and maintained
|
|
60
|
+
* in-memory during the session. These feed the status bar's cumulative
|
|
61
|
+
* `in`, `out`, and `$cost` values; current context usage is estimated
|
|
62
|
+
* separately from the current model-visible history.
|
|
63
|
+
*/
|
|
64
|
+
export interface SessionStats {
|
|
65
|
+
/** Total input tokens across all assistant messages. */
|
|
66
|
+
totalInput: number;
|
|
67
|
+
/** Total output tokens across all assistant messages. */
|
|
68
|
+
totalOutput: number;
|
|
69
|
+
/** Total cost in dollars across all assistant messages. */
|
|
70
|
+
totalCost: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A raw submitted prompt stored for global input-history search. */
|
|
74
|
+
interface PromptHistoryEntry {
|
|
75
|
+
/** Monotonic row id. */
|
|
76
|
+
id: number;
|
|
77
|
+
/** Exact raw prompt text as submitted by the user. */
|
|
78
|
+
text: string;
|
|
79
|
+
/** Working directory where the prompt was submitted. */
|
|
80
|
+
cwd: string;
|
|
81
|
+
/** Originating session id when available. */
|
|
82
|
+
sessionId: string | null;
|
|
83
|
+
/** Unix timestamp in milliseconds when the prompt was submitted. */
|
|
84
|
+
createdAt: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Options for appending a raw prompt-history entry. */
|
|
88
|
+
interface AppendPromptHistoryOpts {
|
|
89
|
+
/** Exact raw prompt text as submitted by the user. */
|
|
90
|
+
text: string;
|
|
91
|
+
/** Working directory where the prompt was submitted. */
|
|
92
|
+
cwd: string;
|
|
93
|
+
/** Originating session id when available. */
|
|
94
|
+
sessionId?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A persisted UI-only message shown in the conversation log. */
|
|
98
|
+
export interface UiMessage {
|
|
99
|
+
/** Identifies this as an internal UI message. */
|
|
100
|
+
role: "ui";
|
|
101
|
+
/** UI message category for rendering and future behavior. */
|
|
102
|
+
kind: "info";
|
|
103
|
+
/** Display text shown in the conversation log. */
|
|
104
|
+
content: string;
|
|
105
|
+
/** Unix timestamp in milliseconds. */
|
|
106
|
+
timestamp: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Any message persisted in session history. */
|
|
110
|
+
type PersistedMessage = Message | UiMessage;
|
|
111
|
+
|
|
112
|
+
/** Options for creating a new session. */
|
|
113
|
+
interface CreateSessionOpts {
|
|
114
|
+
/** Working directory to scope the session to. */
|
|
115
|
+
cwd: string;
|
|
116
|
+
/** Provider/model identifier, e.g. `"anthropic/claude-sonnet-4-20250514"`. */
|
|
117
|
+
model?: string;
|
|
118
|
+
/** Thinking effort level, e.g. `"medium"`. */
|
|
119
|
+
effort?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Internal row types (map directly to SQLite column names)
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/** Row shape returned by `SELECT * FROM sessions`. */
|
|
127
|
+
type SessionRow = {
|
|
128
|
+
id: string;
|
|
129
|
+
cwd: string;
|
|
130
|
+
model: string | null;
|
|
131
|
+
effort: string | null;
|
|
132
|
+
forked_from: string | null;
|
|
133
|
+
created_at: number;
|
|
134
|
+
updated_at: number;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Row shape returned by the `/session` picker query. */
|
|
138
|
+
type SessionListRow = SessionRow & {
|
|
139
|
+
first_user_message_data: string | null;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Row shape for `SELECT MAX(turn)` queries. */
|
|
143
|
+
type MaxTurnRow = { max_turn: number | null };
|
|
144
|
+
|
|
145
|
+
/** Row shape for `SELECT data` queries. */
|
|
146
|
+
type DataRow = { data: string };
|
|
147
|
+
|
|
148
|
+
const EMPTY_ASSISTANT_USAGE: AssistantMessage["usage"] = {
|
|
149
|
+
input: 0,
|
|
150
|
+
output: 0,
|
|
151
|
+
cacheRead: 0,
|
|
152
|
+
cacheWrite: 0,
|
|
153
|
+
totalTokens: 0,
|
|
154
|
+
cost: {
|
|
155
|
+
input: 0,
|
|
156
|
+
output: 0,
|
|
157
|
+
cacheRead: 0,
|
|
158
|
+
cacheWrite: 0,
|
|
159
|
+
total: 0,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** Row shape returned by `SELECT * FROM prompt_history`. */
|
|
164
|
+
type PromptHistoryRow = {
|
|
165
|
+
id: number;
|
|
166
|
+
text: string;
|
|
167
|
+
cwd: string;
|
|
168
|
+
session_id: string | null;
|
|
169
|
+
created_at: number;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// SQL
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
const SQL = {
|
|
177
|
+
listSessions: `
|
|
178
|
+
SELECT
|
|
179
|
+
sessions.*,
|
|
180
|
+
(
|
|
181
|
+
SELECT data
|
|
182
|
+
FROM messages
|
|
183
|
+
WHERE session_id = sessions.id AND turn IS NOT NULL
|
|
184
|
+
ORDER BY id
|
|
185
|
+
LIMIT 1
|
|
186
|
+
) AS first_user_message_data
|
|
187
|
+
FROM sessions
|
|
188
|
+
WHERE cwd = ?
|
|
189
|
+
ORDER BY updated_at DESC, rowid DESC
|
|
190
|
+
`,
|
|
191
|
+
maxTurn: "SELECT MAX(turn) as max_turn FROM messages WHERE session_id = ?",
|
|
192
|
+
loadMessages: "SELECT data FROM messages WHERE session_id = ? ORDER BY id",
|
|
193
|
+
listPromptHistory:
|
|
194
|
+
"SELECT * FROM prompt_history ORDER BY created_at DESC, id DESC LIMIT ?",
|
|
195
|
+
} as const;
|
|
196
|
+
|
|
197
|
+
const SCHEMA = `
|
|
198
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
199
|
+
id TEXT PRIMARY KEY,
|
|
200
|
+
cwd TEXT NOT NULL,
|
|
201
|
+
model TEXT,
|
|
202
|
+
effort TEXT,
|
|
203
|
+
forked_from TEXT,
|
|
204
|
+
created_at INTEGER NOT NULL,
|
|
205
|
+
updated_at INTEGER NOT NULL
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
|
|
209
|
+
|
|
210
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
211
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
212
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
213
|
+
turn INTEGER,
|
|
214
|
+
data TEXT NOT NULL,
|
|
215
|
+
created_at INTEGER NOT NULL
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, turn);
|
|
219
|
+
|
|
220
|
+
CREATE TABLE IF NOT EXISTS prompt_history (
|
|
221
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
222
|
+
text TEXT NOT NULL,
|
|
223
|
+
cwd TEXT NOT NULL,
|
|
224
|
+
session_id TEXT,
|
|
225
|
+
created_at INTEGER NOT NULL
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
CREATE INDEX IF NOT EXISTS idx_prompt_history_created_at ON prompt_history(created_at, id);
|
|
229
|
+
`;
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Database
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Open (or create) the SQLite database and ensure the schema exists.
|
|
237
|
+
*
|
|
238
|
+
* Enables WAL journal mode for concurrent read performance and foreign
|
|
239
|
+
* keys for cascade deletes. Pass `":memory:"` for an in-memory database
|
|
240
|
+
* (useful in tests).
|
|
241
|
+
*
|
|
242
|
+
* @param path - File path for the database, or `":memory:"` for in-memory.
|
|
243
|
+
* @returns An open {@link Database} handle. The caller is responsible for
|
|
244
|
+
* closing it when done.
|
|
245
|
+
*
|
|
246
|
+
* @example
|
|
247
|
+
* ```ts
|
|
248
|
+
* const db = openDatabase("~/.config/mini-coder/mini-coder.db");
|
|
249
|
+
* // ... use db ...
|
|
250
|
+
* db.close();
|
|
251
|
+
* ```
|
|
252
|
+
*/
|
|
253
|
+
export function openDatabase(path: string): Database {
|
|
254
|
+
const db = new Database(path);
|
|
255
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
256
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
257
|
+
db.exec(SCHEMA);
|
|
258
|
+
return db;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Session CRUD
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
function generateId(): string {
|
|
266
|
+
return crypto.randomUUID();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Create a new session record.
|
|
271
|
+
*
|
|
272
|
+
* @param db - Open database handle.
|
|
273
|
+
* @param opts - Session options (cwd is required; model and effort are optional).
|
|
274
|
+
* @returns The newly created {@link Session}.
|
|
275
|
+
*/
|
|
276
|
+
export function createSession(db: Database, opts: CreateSessionOpts): Session {
|
|
277
|
+
const id = generateId();
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
db.run(
|
|
280
|
+
"INSERT INTO sessions (id, cwd, model, effort, forked_from, created_at, updated_at) VALUES (?, ?, ?, ?, NULL, ?, ?)",
|
|
281
|
+
[id, opts.cwd, opts.model ?? null, opts.effort ?? null, now, now],
|
|
282
|
+
);
|
|
283
|
+
return {
|
|
284
|
+
id,
|
|
285
|
+
cwd: opts.cwd,
|
|
286
|
+
model: opts.model ?? null,
|
|
287
|
+
effort: opts.effort ?? null,
|
|
288
|
+
forkedFrom: null,
|
|
289
|
+
createdAt: now,
|
|
290
|
+
updatedAt: now,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Retrieve a session by its ID.
|
|
296
|
+
*
|
|
297
|
+
* @param db - Open database handle.
|
|
298
|
+
* @param id - The session UUID.
|
|
299
|
+
* @returns The {@link Session}, or `null` if not found.
|
|
300
|
+
*/
|
|
301
|
+
export function getSession(db: Database, id: string): Session | null {
|
|
302
|
+
const row = db
|
|
303
|
+
.query<SessionRow, [string]>("SELECT * FROM sessions WHERE id = ?")
|
|
304
|
+
.get(id);
|
|
305
|
+
if (!row) return null;
|
|
306
|
+
return {
|
|
307
|
+
id: row.id,
|
|
308
|
+
cwd: row.cwd,
|
|
309
|
+
model: row.model,
|
|
310
|
+
effort: row.effort,
|
|
311
|
+
forkedFrom: row.forked_from,
|
|
312
|
+
createdAt: row.created_at,
|
|
313
|
+
updatedAt: row.updated_at,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Collapse preview text into a single readable line. */
|
|
318
|
+
function collapsePreviewText(text: string): string | null {
|
|
319
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
320
|
+
return collapsed.length > 0 ? collapsed : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function getMultipartUserPreview(
|
|
324
|
+
content: Extract<Message, { role: "user" }>["content"],
|
|
325
|
+
): string | null {
|
|
326
|
+
if (typeof content === "string") {
|
|
327
|
+
return collapsePreviewText(content);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const text = content
|
|
331
|
+
.filter(
|
|
332
|
+
(block): block is Extract<(typeof content)[number], { type: "text" }> => {
|
|
333
|
+
return block.type === "text";
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
.map((block) => block.text)
|
|
337
|
+
.join(" ");
|
|
338
|
+
|
|
339
|
+
return collapsePreviewText(text);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isTextContentBlock(
|
|
343
|
+
value: unknown,
|
|
344
|
+
): value is { type: "text"; text: string } {
|
|
345
|
+
const record = toRecord(value);
|
|
346
|
+
return record?.type === "text" && typeof record.text === "string";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function isImageContentBlock(
|
|
350
|
+
value: unknown,
|
|
351
|
+
): value is { type: "image"; data: string; mimeType: string } {
|
|
352
|
+
const record = toRecord(value);
|
|
353
|
+
return (
|
|
354
|
+
record?.type === "image" &&
|
|
355
|
+
typeof record.data === "string" &&
|
|
356
|
+
typeof record.mimeType === "string"
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isThinkingContentBlock(
|
|
361
|
+
value: unknown,
|
|
362
|
+
): value is Extract<AssistantMessage["content"][number], { type: "thinking" }> {
|
|
363
|
+
const record = toRecord(value);
|
|
364
|
+
return record?.type === "thinking" && typeof record.thinking === "string";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function isToolCallContentBlock(
|
|
368
|
+
value: unknown,
|
|
369
|
+
): value is Extract<AssistantMessage["content"][number], { type: "toolCall" }> {
|
|
370
|
+
const record = toRecord(value);
|
|
371
|
+
return (
|
|
372
|
+
record?.type === "toolCall" &&
|
|
373
|
+
typeof record.id === "string" &&
|
|
374
|
+
typeof record.name === "string" &&
|
|
375
|
+
toRecord(record.arguments) !== null
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function isAssistantUsage(value: unknown): value is AssistantMessage["usage"] {
|
|
380
|
+
const usageRecord = toRecord(value);
|
|
381
|
+
const costRecord = toRecord(usageRecord?.cost);
|
|
382
|
+
return (
|
|
383
|
+
usageRecord !== null &&
|
|
384
|
+
costRecord !== null &&
|
|
385
|
+
readFiniteNumber(usageRecord, "input") !== null &&
|
|
386
|
+
readFiniteNumber(usageRecord, "output") !== null &&
|
|
387
|
+
readFiniteNumber(usageRecord, "cacheRead") !== null &&
|
|
388
|
+
readFiniteNumber(usageRecord, "cacheWrite") !== null &&
|
|
389
|
+
readFiniteNumber(usageRecord, "totalTokens") !== null &&
|
|
390
|
+
readFiniteNumber(costRecord, "input") !== null &&
|
|
391
|
+
readFiniteNumber(costRecord, "output") !== null &&
|
|
392
|
+
readFiniteNumber(costRecord, "cacheRead") !== null &&
|
|
393
|
+
readFiniteNumber(costRecord, "cacheWrite") !== null &&
|
|
394
|
+
readFiniteNumber(costRecord, "total") !== null
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function isStopReason(value: unknown): value is AssistantMessage["stopReason"] {
|
|
399
|
+
return (
|
|
400
|
+
value === "stop" ||
|
|
401
|
+
value === "length" ||
|
|
402
|
+
value === "toolUse" ||
|
|
403
|
+
value === "error" ||
|
|
404
|
+
value === "aborted"
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function isUserMessageRecord(value: unknown): value is UserMessage {
|
|
409
|
+
const record = toRecord(value);
|
|
410
|
+
if (!record || record.role !== "user") {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
readFiniteNumber(record, "timestamp") !== null &&
|
|
416
|
+
(typeof record.content === "string" ||
|
|
417
|
+
(Array.isArray(record.content) &&
|
|
418
|
+
record.content.every(
|
|
419
|
+
(block) => isTextContentBlock(block) || isImageContentBlock(block),
|
|
420
|
+
)))
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function parseAssistantMessageRecord(value: unknown): AssistantMessage | null {
|
|
425
|
+
const record = toRecord(value);
|
|
426
|
+
if (!record || record.role !== "assistant") {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const timestamp = readFiniteNumber(record, "timestamp");
|
|
431
|
+
if (
|
|
432
|
+
!Array.isArray(record.content) ||
|
|
433
|
+
!record.content.every(
|
|
434
|
+
(block) =>
|
|
435
|
+
isTextContentBlock(block) ||
|
|
436
|
+
isThinkingContentBlock(block) ||
|
|
437
|
+
isToolCallContentBlock(block),
|
|
438
|
+
) ||
|
|
439
|
+
typeof record.api !== "string" ||
|
|
440
|
+
typeof record.provider !== "string" ||
|
|
441
|
+
typeof record.model !== "string" ||
|
|
442
|
+
!isStopReason(record.stopReason) ||
|
|
443
|
+
(record.errorMessage !== undefined &&
|
|
444
|
+
typeof record.errorMessage !== "string") ||
|
|
445
|
+
timestamp === null
|
|
446
|
+
) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
role: "assistant",
|
|
452
|
+
content: record.content,
|
|
453
|
+
api: record.api,
|
|
454
|
+
provider: record.provider,
|
|
455
|
+
model: record.model,
|
|
456
|
+
usage: isAssistantUsage(record.usage)
|
|
457
|
+
? record.usage
|
|
458
|
+
: structuredClone(EMPTY_ASSISTANT_USAGE),
|
|
459
|
+
stopReason: record.stopReason,
|
|
460
|
+
...(typeof record.errorMessage === "string"
|
|
461
|
+
? { errorMessage: record.errorMessage }
|
|
462
|
+
: {}),
|
|
463
|
+
timestamp,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function isToolResultMessageRecord(value: unknown): value is ToolResultMessage {
|
|
468
|
+
const record = toRecord(value);
|
|
469
|
+
if (!record || record.role !== "toolResult") {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
typeof record.toolCallId === "string" &&
|
|
475
|
+
typeof record.toolName === "string" &&
|
|
476
|
+
typeof record.isError === "boolean" &&
|
|
477
|
+
Array.isArray(record.content) &&
|
|
478
|
+
record.content.every(
|
|
479
|
+
(block) => isTextContentBlock(block) || isImageContentBlock(block),
|
|
480
|
+
) &&
|
|
481
|
+
readFiniteNumber(record, "timestamp") !== null
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function isUiMessageRecord(value: unknown): value is UiMessage {
|
|
486
|
+
const record = toRecord(value);
|
|
487
|
+
if (!record || record.role !== "ui") {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
record.kind === "info" &&
|
|
493
|
+
typeof record.content === "string" &&
|
|
494
|
+
readFiniteNumber(record, "timestamp") !== null
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function parsePersistedMessage(data: string): PersistedMessage | null {
|
|
499
|
+
let parsed: unknown;
|
|
500
|
+
try {
|
|
501
|
+
parsed = JSON.parse(data) as unknown;
|
|
502
|
+
} catch {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (
|
|
507
|
+
isUserMessageRecord(parsed) ||
|
|
508
|
+
isToolResultMessageRecord(parsed) ||
|
|
509
|
+
isUiMessageRecord(parsed)
|
|
510
|
+
) {
|
|
511
|
+
return parsed;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return parseAssistantMessageRecord(parsed);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Read the first-user preview cached by the session-list query. */
|
|
518
|
+
function readFirstUserPreview(messageData: string | null): string | null {
|
|
519
|
+
if (!messageData) {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const message = parsePersistedMessage(messageData);
|
|
524
|
+
if (!message || message.role !== "user") {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return getMultipartUserPreview(message.content);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* List sessions for a working directory, most recently updated first.
|
|
533
|
+
*
|
|
534
|
+
* @param db - Open database handle.
|
|
535
|
+
* @param cwd - Working directory to filter by.
|
|
536
|
+
* @returns Session rows ordered by `updatedAt` descending, enriched with the first-user preview.
|
|
537
|
+
*/
|
|
538
|
+
export function listSessions(db: Database, cwd: string): SessionListEntry[] {
|
|
539
|
+
const rows = db.query<SessionListRow, [string]>(SQL.listSessions).all(cwd);
|
|
540
|
+
return rows.map((row) => ({
|
|
541
|
+
id: row.id,
|
|
542
|
+
cwd: row.cwd,
|
|
543
|
+
model: row.model,
|
|
544
|
+
effort: row.effort,
|
|
545
|
+
forkedFrom: row.forked_from,
|
|
546
|
+
createdAt: row.created_at,
|
|
547
|
+
updatedAt: row.updated_at,
|
|
548
|
+
firstUserPreview: readFirstUserPreview(row.first_user_message_data),
|
|
549
|
+
}));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Delete a session and all its messages (via foreign key cascade).
|
|
554
|
+
*
|
|
555
|
+
* @param db - Open database handle.
|
|
556
|
+
* @param id - The session UUID to delete.
|
|
557
|
+
*/
|
|
558
|
+
export function deleteSession(db: Database, id: string): void {
|
|
559
|
+
db.run("DELETE FROM sessions WHERE id = ?", [id]);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Keep only the most recent sessions for a CWD, deleting the rest.
|
|
564
|
+
*
|
|
565
|
+
* Sessions are ordered by `updated_at DESC`; those beyond `keep` are
|
|
566
|
+
* deleted (cascade removes their messages too). No-op if the count is
|
|
567
|
+
* already within the limit.
|
|
568
|
+
*
|
|
569
|
+
* @param db - Open database handle.
|
|
570
|
+
* @param cwd - Working directory to scope the truncation to.
|
|
571
|
+
* @param keep - Maximum number of sessions to retain.
|
|
572
|
+
*/
|
|
573
|
+
export function truncateSessions(
|
|
574
|
+
db: Database,
|
|
575
|
+
cwd: string,
|
|
576
|
+
keep: number,
|
|
577
|
+
): void {
|
|
578
|
+
db.run(
|
|
579
|
+
`DELETE FROM sessions WHERE id IN (
|
|
580
|
+
SELECT id FROM sessions WHERE cwd = ?
|
|
581
|
+
ORDER BY updated_at DESC, rowid DESC
|
|
582
|
+
LIMIT -1 OFFSET ?
|
|
583
|
+
)`,
|
|
584
|
+
[cwd, keep],
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
// Messages
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Create a persisted UI message.
|
|
594
|
+
*
|
|
595
|
+
* @param content - Display text shown in the conversation log.
|
|
596
|
+
* @returns A new {@link UiMessage}.
|
|
597
|
+
*/
|
|
598
|
+
export function createUiMessage(content: string): UiMessage {
|
|
599
|
+
return {
|
|
600
|
+
role: "ui",
|
|
601
|
+
kind: "info",
|
|
602
|
+
content,
|
|
603
|
+
timestamp: Date.now(),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Check whether a persisted message is a UI-only message.
|
|
609
|
+
*
|
|
610
|
+
* @param message - Message to inspect.
|
|
611
|
+
* @returns `true` when the message is a {@link UiMessage}.
|
|
612
|
+
*/
|
|
613
|
+
function isUiMessage(message: PersistedMessage): message is UiMessage {
|
|
614
|
+
return message.role === "ui";
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Filter persisted session history down to model-visible pi-ai messages.
|
|
619
|
+
*
|
|
620
|
+
* @param messages - Persisted session history.
|
|
621
|
+
* @returns Only the pi-ai {@link Message} entries.
|
|
622
|
+
*/
|
|
623
|
+
export function filterModelMessages(
|
|
624
|
+
messages: readonly PersistedMessage[],
|
|
625
|
+
): Message[] {
|
|
626
|
+
return messages.filter(
|
|
627
|
+
(message): message is Message => !isUiMessage(message),
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function toRecord(value: unknown): Record<string, unknown> | null {
|
|
632
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
633
|
+
? (value as Record<string, unknown>)
|
|
634
|
+
: null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function readFiniteNumber(
|
|
638
|
+
record: Record<string, unknown>,
|
|
639
|
+
key: string,
|
|
640
|
+
): number | null {
|
|
641
|
+
const value = record[key];
|
|
642
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Return an assistant message's usage when the persisted shape is valid.
|
|
647
|
+
*
|
|
648
|
+
* Session rows are treated as untrusted at runtime because older builds or
|
|
649
|
+
* external tooling may have stored assistant messages without a `usage`
|
|
650
|
+
* payload. Invalid or missing usage is ignored instead of crashing session
|
|
651
|
+
* loading or stats calculations.
|
|
652
|
+
*
|
|
653
|
+
* @param message - Message to inspect.
|
|
654
|
+
* @returns The assistant usage payload, or `null` when it is missing/invalid.
|
|
655
|
+
*/
|
|
656
|
+
export function getAssistantUsage(
|
|
657
|
+
message: PersistedMessage | Message,
|
|
658
|
+
): AssistantMessage["usage"] | null {
|
|
659
|
+
if (message.role !== "assistant") {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const messageRecord = toRecord(message);
|
|
664
|
+
const usageRecord = toRecord(messageRecord?.usage);
|
|
665
|
+
const costRecord = toRecord(usageRecord?.cost);
|
|
666
|
+
if (!usageRecord || !costRecord) {
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const input = readFiniteNumber(usageRecord, "input");
|
|
671
|
+
const output = readFiniteNumber(usageRecord, "output");
|
|
672
|
+
const cacheRead = readFiniteNumber(usageRecord, "cacheRead");
|
|
673
|
+
const cacheWrite = readFiniteNumber(usageRecord, "cacheWrite");
|
|
674
|
+
const totalTokens = readFiniteNumber(usageRecord, "totalTokens");
|
|
675
|
+
const costInput = readFiniteNumber(costRecord, "input");
|
|
676
|
+
const costOutput = readFiniteNumber(costRecord, "output");
|
|
677
|
+
const costCacheRead = readFiniteNumber(costRecord, "cacheRead");
|
|
678
|
+
const costCacheWrite = readFiniteNumber(costRecord, "cacheWrite");
|
|
679
|
+
const costTotal = readFiniteNumber(costRecord, "total");
|
|
680
|
+
|
|
681
|
+
if (
|
|
682
|
+
input === null ||
|
|
683
|
+
output === null ||
|
|
684
|
+
cacheRead === null ||
|
|
685
|
+
cacheWrite === null ||
|
|
686
|
+
totalTokens === null ||
|
|
687
|
+
costInput === null ||
|
|
688
|
+
costOutput === null ||
|
|
689
|
+
costCacheRead === null ||
|
|
690
|
+
costCacheWrite === null ||
|
|
691
|
+
costTotal === null
|
|
692
|
+
) {
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
input,
|
|
698
|
+
output,
|
|
699
|
+
cacheRead,
|
|
700
|
+
cacheWrite,
|
|
701
|
+
totalTokens,
|
|
702
|
+
cost: {
|
|
703
|
+
input: costInput,
|
|
704
|
+
output: costOutput,
|
|
705
|
+
cacheRead: costCacheRead,
|
|
706
|
+
cacheWrite: costCacheWrite,
|
|
707
|
+
total: costTotal,
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Append a UI-only message to a session's history.
|
|
714
|
+
*
|
|
715
|
+
* UI messages are persisted with `turn = NULL` so they remain visible in
|
|
716
|
+
* history without participating in conversational turn numbering or `/undo`.
|
|
717
|
+
*
|
|
718
|
+
* @param db - Open database handle.
|
|
719
|
+
* @param sessionId - The session to append to.
|
|
720
|
+
* @param message - The UI-only message to persist.
|
|
721
|
+
* @param turn - Ignored for UI messages.
|
|
722
|
+
* @returns `null`, since UI messages do not belong to conversational turns.
|
|
723
|
+
*/
|
|
724
|
+
export function appendMessage(
|
|
725
|
+
db: Database,
|
|
726
|
+
sessionId: string,
|
|
727
|
+
message: UiMessage,
|
|
728
|
+
turn?: number,
|
|
729
|
+
): null;
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Append a conversational message to a session's history.
|
|
733
|
+
*
|
|
734
|
+
* Turn numbering rules:
|
|
735
|
+
* - When `turn` is **omitted**, a new turn is started with `MAX(turn) + 1`
|
|
736
|
+
* (or `1` for the first message). This is used for user messages.
|
|
737
|
+
* - When `turn` is **provided**, the message joins that existing turn.
|
|
738
|
+
* This is used for assistant and tool-result messages that belong to
|
|
739
|
+
* the same agent loop as the initiating user message.
|
|
740
|
+
*
|
|
741
|
+
* Also updates the session's `updatedAt` timestamp.
|
|
742
|
+
*
|
|
743
|
+
* @param db - Open database handle.
|
|
744
|
+
* @param sessionId - The session to append to.
|
|
745
|
+
* @param message - A model-visible pi-ai message.
|
|
746
|
+
* @param turn - Explicit turn number to join. Omit to start a new turn.
|
|
747
|
+
* @returns The conversational turn number the message was stored with.
|
|
748
|
+
*/
|
|
749
|
+
export function appendMessage(
|
|
750
|
+
db: Database,
|
|
751
|
+
sessionId: string,
|
|
752
|
+
message: Message,
|
|
753
|
+
turn?: number,
|
|
754
|
+
): number;
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Append a persisted message to a session's history.
|
|
758
|
+
*
|
|
759
|
+
* UI messages always store `turn = NULL`. Conversational messages either start
|
|
760
|
+
* a new turn or join an existing one, depending on `turn`.
|
|
761
|
+
*
|
|
762
|
+
* @param db - Open database handle.
|
|
763
|
+
* @param sessionId - The session to append to.
|
|
764
|
+
* @param message - Persisted message to store.
|
|
765
|
+
* @param turn - Explicit conversational turn to join.
|
|
766
|
+
* @returns The assigned conversational turn number, or `null` for UI messages.
|
|
767
|
+
*/
|
|
768
|
+
export function appendMessage(
|
|
769
|
+
db: Database,
|
|
770
|
+
sessionId: string,
|
|
771
|
+
message: PersistedMessage,
|
|
772
|
+
turn?: number,
|
|
773
|
+
): number | null;
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Append a persisted message to a session's history.
|
|
777
|
+
*
|
|
778
|
+
* UI messages always store `turn = NULL`. Conversational messages either start
|
|
779
|
+
* a new turn or join an existing one, depending on `turn`.
|
|
780
|
+
*
|
|
781
|
+
* @param db - Open database handle.
|
|
782
|
+
* @param sessionId - The session to append to.
|
|
783
|
+
* @param message - Persisted message to store.
|
|
784
|
+
* @param turn - Explicit conversational turn to join.
|
|
785
|
+
* @returns The assigned conversational turn number, or `null` for UI messages.
|
|
786
|
+
*/
|
|
787
|
+
export function appendMessage(
|
|
788
|
+
db: Database,
|
|
789
|
+
sessionId: string,
|
|
790
|
+
message: PersistedMessage,
|
|
791
|
+
turn?: number,
|
|
792
|
+
): number | null {
|
|
793
|
+
const now = Date.now();
|
|
794
|
+
|
|
795
|
+
let effectiveTurn: number | null;
|
|
796
|
+
if (isUiMessage(message)) {
|
|
797
|
+
effectiveTurn = null;
|
|
798
|
+
} else if (turn !== undefined) {
|
|
799
|
+
effectiveTurn = turn;
|
|
800
|
+
} else {
|
|
801
|
+
const row = db.query<MaxTurnRow, [string]>(SQL.maxTurn).get(sessionId);
|
|
802
|
+
effectiveTurn = (row?.max_turn ?? 0) + 1;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
db.run(
|
|
806
|
+
"INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
|
|
807
|
+
[sessionId, effectiveTurn, JSON.stringify(message), now],
|
|
808
|
+
);
|
|
809
|
+
db.run("UPDATE sessions SET updated_at = ? WHERE id = ?", [now, sessionId]);
|
|
810
|
+
|
|
811
|
+
return effectiveTurn;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Load all messages for a session in insertion order.
|
|
816
|
+
*
|
|
817
|
+
* Messages are deserialized from their JSON representation back into
|
|
818
|
+
* persisted app messages. Invalid rows are skipped so corrupted session data
|
|
819
|
+
* does not crash the app. The ordering matches the original append order
|
|
820
|
+
* (by autoincrement `id`), preserving the conversation flow.
|
|
821
|
+
*
|
|
822
|
+
* @param db - Open database handle.
|
|
823
|
+
* @param sessionId - The session to load messages for.
|
|
824
|
+
* @returns An array of {@link PersistedMessage} objects, empty if the session
|
|
825
|
+
* has no messages or does not exist.
|
|
826
|
+
*/
|
|
827
|
+
export function loadMessages(
|
|
828
|
+
db: Database,
|
|
829
|
+
sessionId: string,
|
|
830
|
+
): PersistedMessage[] {
|
|
831
|
+
const rows = db.query<DataRow, [string]>(SQL.loadMessages).all(sessionId);
|
|
832
|
+
const messages: PersistedMessage[] = [];
|
|
833
|
+
|
|
834
|
+
for (const row of rows) {
|
|
835
|
+
const message = parsePersistedMessage(row.data);
|
|
836
|
+
if (message) {
|
|
837
|
+
messages.push(message);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return messages;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
// Prompt history
|
|
846
|
+
// ---------------------------------------------------------------------------
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Append a raw submitted prompt to the global prompt-history table.
|
|
850
|
+
*
|
|
851
|
+
* This history is separate from conversational turn state: it is global,
|
|
852
|
+
* append-only, and not affected by `/undo`.
|
|
853
|
+
*
|
|
854
|
+
* @param db - Open database handle.
|
|
855
|
+
* @param opts - Prompt-history fields to persist.
|
|
856
|
+
* @returns The stored {@link PromptHistoryEntry}.
|
|
857
|
+
*/
|
|
858
|
+
export function appendPromptHistory(
|
|
859
|
+
db: Database,
|
|
860
|
+
opts: AppendPromptHistoryOpts,
|
|
861
|
+
): PromptHistoryEntry {
|
|
862
|
+
const now = Date.now();
|
|
863
|
+
const result = db.run(
|
|
864
|
+
"INSERT INTO prompt_history (text, cwd, session_id, created_at) VALUES (?, ?, ?, ?)",
|
|
865
|
+
[opts.text, opts.cwd, opts.sessionId ?? null, now],
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
return {
|
|
869
|
+
id: Number(result.lastInsertRowid),
|
|
870
|
+
text: opts.text,
|
|
871
|
+
cwd: opts.cwd,
|
|
872
|
+
sessionId: opts.sessionId ?? null,
|
|
873
|
+
createdAt: now,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* List raw submitted prompts newest first.
|
|
879
|
+
*
|
|
880
|
+
* @param db - Open database handle.
|
|
881
|
+
* @param limit - Maximum number of entries to return.
|
|
882
|
+
* @returns Prompt-history entries ordered newest first.
|
|
883
|
+
*/
|
|
884
|
+
export function listPromptHistory(
|
|
885
|
+
db: Database,
|
|
886
|
+
limit = Number.MAX_SAFE_INTEGER,
|
|
887
|
+
): PromptHistoryEntry[] {
|
|
888
|
+
const rows = db
|
|
889
|
+
.query<PromptHistoryRow, [number]>(SQL.listPromptHistory)
|
|
890
|
+
.all(limit);
|
|
891
|
+
return rows.map((row) => ({
|
|
892
|
+
id: row.id,
|
|
893
|
+
text: row.text,
|
|
894
|
+
cwd: row.cwd,
|
|
895
|
+
sessionId: row.session_id,
|
|
896
|
+
createdAt: row.created_at,
|
|
897
|
+
}));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Keep only the newest prompt-history rows.
|
|
902
|
+
*
|
|
903
|
+
* @param db - Open database handle.
|
|
904
|
+
* @param keep - Maximum number of prompt-history rows to retain.
|
|
905
|
+
*/
|
|
906
|
+
export function truncatePromptHistory(db: Database, keep: number): void {
|
|
907
|
+
db.run(
|
|
908
|
+
`DELETE FROM prompt_history WHERE id IN (
|
|
909
|
+
SELECT id FROM prompt_history
|
|
910
|
+
ORDER BY created_at DESC, id DESC
|
|
911
|
+
LIMIT -1 OFFSET ?
|
|
912
|
+
)`,
|
|
913
|
+
[keep],
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ---------------------------------------------------------------------------
|
|
918
|
+
// Undo
|
|
919
|
+
// ---------------------------------------------------------------------------
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Remove the last turn from a session's history.
|
|
923
|
+
*
|
|
924
|
+
* Deletes **all** messages with the highest turn number — the user message
|
|
925
|
+
* and every assistant/tool-result message that followed in the same agent
|
|
926
|
+
* loop. This is a context-only operation; filesystem changes are not reverted.
|
|
927
|
+
*
|
|
928
|
+
* @param db - Open database handle.
|
|
929
|
+
* @param sessionId - The session to undo in.
|
|
930
|
+
* @returns `true` if a turn was removed, `false` if the session had no messages.
|
|
931
|
+
*/
|
|
932
|
+
export function undoLastTurn(db: Database, sessionId: string): boolean {
|
|
933
|
+
const row = db.query<MaxTurnRow, [string]>(SQL.maxTurn).get(sessionId);
|
|
934
|
+
if (!row?.max_turn) return false;
|
|
935
|
+
|
|
936
|
+
db.run("DELETE FROM messages WHERE session_id = ? AND turn = ?", [
|
|
937
|
+
sessionId,
|
|
938
|
+
row.max_turn,
|
|
939
|
+
]);
|
|
940
|
+
return true;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ---------------------------------------------------------------------------
|
|
944
|
+
// Fork
|
|
945
|
+
// ---------------------------------------------------------------------------
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Fork a session into a new independent copy.
|
|
949
|
+
*
|
|
950
|
+
* Creates a new session with the same `cwd`, `model`, and `effort` as the
|
|
951
|
+
* source, then copies all messages preserving their turn numbers. The new
|
|
952
|
+
* session's `forkedFrom` field points back to the source. The original
|
|
953
|
+
* session is not modified.
|
|
954
|
+
*
|
|
955
|
+
* @param db - Open database handle.
|
|
956
|
+
* @param sourceId - The session to fork from.
|
|
957
|
+
* @returns The newly created {@link Session}.
|
|
958
|
+
* @throws If the source session does not exist.
|
|
959
|
+
*/
|
|
960
|
+
export function forkSession(db: Database, sourceId: string): Session {
|
|
961
|
+
const source = getSession(db, sourceId);
|
|
962
|
+
if (!source) throw new Error(`Session not found: ${sourceId}`);
|
|
963
|
+
|
|
964
|
+
const id = generateId();
|
|
965
|
+
const now = Date.now();
|
|
966
|
+
|
|
967
|
+
db.run(
|
|
968
|
+
"INSERT INTO sessions (id, cwd, model, effort, forked_from, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
969
|
+
[id, source.cwd, source.model, source.effort, sourceId, now, now],
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
db.run(
|
|
973
|
+
"INSERT INTO messages (session_id, turn, data, created_at) SELECT ?, turn, data, created_at FROM messages WHERE session_id = ? ORDER BY id",
|
|
974
|
+
[id, sourceId],
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
return {
|
|
978
|
+
id,
|
|
979
|
+
cwd: source.cwd,
|
|
980
|
+
model: source.model,
|
|
981
|
+
effort: source.effort,
|
|
982
|
+
forkedFrom: sourceId,
|
|
983
|
+
createdAt: now,
|
|
984
|
+
updatedAt: now,
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// ---------------------------------------------------------------------------
|
|
989
|
+
// Stats
|
|
990
|
+
// ---------------------------------------------------------------------------
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Compute cumulative token and cost statistics from a message history.
|
|
994
|
+
*
|
|
995
|
+
* Iterates over the messages, summing `usage` fields from assistant messages
|
|
996
|
+
* only (user and tool-result messages do not carry usage data). This is
|
|
997
|
+
* designed to be called once on session load, with the result maintained
|
|
998
|
+
* in-memory via a running accumulator during the session.
|
|
999
|
+
*
|
|
1000
|
+
* @param messages - The full persisted message history for a session.
|
|
1001
|
+
* @returns Aggregated {@link SessionStats}.
|
|
1002
|
+
*/
|
|
1003
|
+
/**
|
|
1004
|
+
* Add one persisted message's assistant usage to cumulative session stats.
|
|
1005
|
+
*
|
|
1006
|
+
* Non-assistant messages and assistant messages without valid `usage` are
|
|
1007
|
+
* ignored and return the original totals unchanged.
|
|
1008
|
+
*
|
|
1009
|
+
* @param stats - Running cumulative session totals.
|
|
1010
|
+
* @param message - Persisted message to fold into the totals.
|
|
1011
|
+
* @returns Updated cumulative session stats.
|
|
1012
|
+
*/
|
|
1013
|
+
export function addMessageToStats(
|
|
1014
|
+
stats: SessionStats,
|
|
1015
|
+
message: PersistedMessage,
|
|
1016
|
+
): SessionStats {
|
|
1017
|
+
const usage = getAssistantUsage(message);
|
|
1018
|
+
if (!usage) {
|
|
1019
|
+
return stats;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return {
|
|
1023
|
+
totalInput: stats.totalInput + usage.input,
|
|
1024
|
+
totalOutput: stats.totalOutput + usage.output,
|
|
1025
|
+
totalCost: stats.totalCost + usage.cost.total,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
export function computeStats(
|
|
1030
|
+
messages: readonly PersistedMessage[],
|
|
1031
|
+
): SessionStats {
|
|
1032
|
+
let stats: SessionStats = {
|
|
1033
|
+
totalInput: 0,
|
|
1034
|
+
totalOutput: 0,
|
|
1035
|
+
totalCost: 0,
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
for (const message of messages) {
|
|
1039
|
+
stats = addMessageToStats(stats, message);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
return stats;
|
|
1043
|
+
}
|