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