mouaif 0.3.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/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- package/src/util.js +151 -0
package/src/chatdb.js
ADDED
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Chat DB — SQLite-backed chat and message storage.
|
|
4
|
+
//
|
|
5
|
+
// Implements the "store chats in DB by default" design:
|
|
6
|
+
// - chat metadata in `chat_store` table
|
|
7
|
+
// - messages in `message_store` table
|
|
8
|
+
// - both in the same ~/.mouaif/store.sqlite used by settings.js
|
|
9
|
+
//
|
|
10
|
+
// This module exposes the DB helpers. The public API surface remains
|
|
11
|
+
// src/chats.js and src/messages.js, which route through this module.
|
|
12
|
+
// It is the only chat/message storage backend.
|
|
13
|
+
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { assertChatId, CHAT_ID_RE, normalizeMessage } = require('./messages.js');
|
|
16
|
+
|
|
17
|
+
// ---- Schema DDL – created lazily via IF NOT EXISTS ------------------------
|
|
18
|
+
|
|
19
|
+
const CREATE_CHAT_TABLE = `
|
|
20
|
+
CREATE TABLE IF NOT EXISTS chat_store (
|
|
21
|
+
project_dir TEXT NOT NULL,
|
|
22
|
+
id TEXT NOT NULL,
|
|
23
|
+
title TEXT NOT NULL DEFAULT 'New chat',
|
|
24
|
+
created_at TEXT NOT NULL,
|
|
25
|
+
last_opened_at TEXT,
|
|
26
|
+
trace INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
prompt_size TEXT NOT NULL DEFAULT 'average',
|
|
28
|
+
prompt_id TEXT,
|
|
29
|
+
provider_id TEXT,
|
|
30
|
+
model_id TEXT,
|
|
31
|
+
thinking_level TEXT DEFAULT '',
|
|
32
|
+
max_output_tokens TEXT DEFAULT '',
|
|
33
|
+
draft TEXT NOT NULL DEFAULT '',
|
|
34
|
+
draft_attachments TEXT,
|
|
35
|
+
tools TEXT,
|
|
36
|
+
agent_id TEXT,
|
|
37
|
+
agent_files INTEGER,
|
|
38
|
+
skills INTEGER,
|
|
39
|
+
disabled_skills TEXT,
|
|
40
|
+
tool_auth TEXT,
|
|
41
|
+
auto_retry INTEGER NOT NULL DEFAULT 1,
|
|
42
|
+
total_cost REAL NOT NULL DEFAULT 0,
|
|
43
|
+
cost_known_count INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
PRIMARY KEY (project_dir, id)
|
|
45
|
+
)
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const CREATE_MESSAGE_TABLE = `
|
|
49
|
+
CREATE TABLE IF NOT EXISTS message_store (
|
|
50
|
+
project_dir TEXT NOT NULL,
|
|
51
|
+
chat_id TEXT NOT NULL,
|
|
52
|
+
seq INTEGER NOT NULL,
|
|
53
|
+
role TEXT NOT NULL,
|
|
54
|
+
content TEXT NOT NULL,
|
|
55
|
+
ts TEXT NOT NULL,
|
|
56
|
+
reasoning TEXT,
|
|
57
|
+
usage TEXT,
|
|
58
|
+
cost TEXT,
|
|
59
|
+
streaming_ms INTEGER,
|
|
60
|
+
model_id TEXT,
|
|
61
|
+
attachments TEXT,
|
|
62
|
+
tool_call_id TEXT,
|
|
63
|
+
name TEXT,
|
|
64
|
+
args TEXT,
|
|
65
|
+
ok INTEGER,
|
|
66
|
+
phase TEXT,
|
|
67
|
+
PRIMARY KEY (project_dir, chat_id, seq)
|
|
68
|
+
)
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
// ---- Lazy DB init (reuses settings.js connection) -------------------------
|
|
72
|
+
|
|
73
|
+
const INDEX_SQL = `
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_chat_store_recent
|
|
75
|
+
ON chat_store (project_dir, COALESCE(last_opened_at, created_at) DESC, id DESC);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_message_store_lookup
|
|
77
|
+
ON message_store (project_dir, chat_id, seq);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_message_store_cost
|
|
79
|
+
ON message_store (project_dir, chat_id)
|
|
80
|
+
WHERE role = 'assistant' AND cost IS NOT NULL
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
function ensureTables() {
|
|
84
|
+
const settings = require('./settings.js');
|
|
85
|
+
const d = settings.getDb();
|
|
86
|
+
d.exec(CREATE_CHAT_TABLE);
|
|
87
|
+
d.exec(CREATE_MESSAGE_TABLE);
|
|
88
|
+
const chatColumns = d.prepare("PRAGMA table_info('chat_store')").all();
|
|
89
|
+
if (!chatColumns.some((column) => column.name === 'total_cost')) {
|
|
90
|
+
d.exec('ALTER TABLE chat_store ADD COLUMN total_cost REAL NOT NULL DEFAULT 0');
|
|
91
|
+
}
|
|
92
|
+
if (!chatColumns.some((column) => column.name === 'cost_known_count')) {
|
|
93
|
+
d.exec('ALTER TABLE chat_store ADD COLUMN cost_known_count INTEGER NOT NULL DEFAULT 0');
|
|
94
|
+
}
|
|
95
|
+
if (!chatColumns.some((column) => column.name === 'auto_retry')) {
|
|
96
|
+
d.exec('ALTER TABLE chat_store ADD COLUMN auto_retry INTEGER NOT NULL DEFAULT 1');
|
|
97
|
+
}
|
|
98
|
+
// Per-chat skill opt-outs (JSON array of skill ids). NULL means "no
|
|
99
|
+
// per-skill opt-outs", which is what every chat written before this
|
|
100
|
+
// column existed has.
|
|
101
|
+
if (!chatColumns.some((column) => column.name === 'disabled_skills')) {
|
|
102
|
+
d.exec('ALTER TABLE chat_store ADD COLUMN disabled_skills TEXT');
|
|
103
|
+
}
|
|
104
|
+
// Per-chat tool authorization overrides (decisions §17): the Off / Ask /
|
|
105
|
+
// Allow choice made in the chat's own Tools card and composer tool popup.
|
|
106
|
+
// NULL means "inherit the project / app value", which is what every chat
|
|
107
|
+
// written before this column existed has and what a chat goes back to
|
|
108
|
+
// when the user has not touched a tool's mode in this chat.
|
|
109
|
+
if (!chatColumns.some((column) => column.name === 'tool_auth')) {
|
|
110
|
+
d.exec('ALTER TABLE chat_store ADD COLUMN tool_auth TEXT');
|
|
111
|
+
}
|
|
112
|
+
d.exec(INDEX_SQL);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---- Row <-> object mappers -----------------------------------------------
|
|
116
|
+
|
|
117
|
+
function rowToChat(row) {
|
|
118
|
+
if (!row) return null;
|
|
119
|
+
const chat = {
|
|
120
|
+
id: row.id,
|
|
121
|
+
title: row.title,
|
|
122
|
+
createdAt: row.created_at,
|
|
123
|
+
lastOpenedAt: row.last_opened_at || null,
|
|
124
|
+
trace: row.trace === 1,
|
|
125
|
+
promptSize: row.prompt_size,
|
|
126
|
+
promptId: row.prompt_id || null,
|
|
127
|
+
providerId: row.provider_id || null,
|
|
128
|
+
modelId: row.model_id || null,
|
|
129
|
+
thinkingLevel: row.thinking_level || '',
|
|
130
|
+
maxOutputTokens: row.max_output_tokens || '',
|
|
131
|
+
draft: row.draft || '',
|
|
132
|
+
agentFiles: row.agent_files === null ? undefined : (row.agent_files === 1),
|
|
133
|
+
skills: row.skills === null ? undefined : (row.skills === 1),
|
|
134
|
+
// disabledSkills: NULL -> undefined (no per-chat opt-outs); otherwise the
|
|
135
|
+
// JSON array of skill ids this chat switched off one by one. A chat can
|
|
136
|
+
// carry this list while the family flag above stays unset/true — that is
|
|
137
|
+
// what a per-skill switch means.
|
|
138
|
+
autoRetry: row.auto_retry !== 0,
|
|
139
|
+
totalCost: {
|
|
140
|
+
total: typeof row.total_cost === 'number' ? row.total_cost : 0,
|
|
141
|
+
known: typeof row.cost_known_count === 'number' && row.cost_known_count > 0,
|
|
142
|
+
currency: 'USD',
|
|
143
|
+
knownCount: typeof row.cost_known_count === 'number' ? row.cost_known_count : 0
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
// draftAttachments: null/'' -> undefined (no image draft); otherwise the
|
|
147
|
+
// JSON array of pending image attachments for the composer.
|
|
148
|
+
if (row.draft_attachments) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(row.draft_attachments);
|
|
151
|
+
if (Array.isArray(parsed)) chat.draftAttachments = parsed;
|
|
152
|
+
} catch { /* keep undefined */ }
|
|
153
|
+
}
|
|
154
|
+
// tools: null/undefined -> all tools; [] -> advertise none; [names] -> filter
|
|
155
|
+
if (row.tools !== null) {
|
|
156
|
+
try { chat.tools = JSON.parse(row.tools); } catch { /* keep undefined */ }
|
|
157
|
+
}
|
|
158
|
+
if (row.disabled_skills) {
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(row.disabled_skills);
|
|
161
|
+
if (Array.isArray(parsed) && parsed.length) {
|
|
162
|
+
chat.disabledSkills = parsed.map((n) => String(n)).filter(Boolean);
|
|
163
|
+
}
|
|
164
|
+
} catch { /* keep undefined */ }
|
|
165
|
+
}
|
|
166
|
+
// toolAuth: this chat's own Off/Ask/Allow overrides, keyed by tool name
|
|
167
|
+
// (`shell`, `file`, …) for native tools and by full composed name
|
|
168
|
+
// (`mcp__<slug>__<tool>`) for MCP ones. NULL/{} -> undefined, meaning
|
|
169
|
+
// every tool inherits the project / app gate. See decisions §17.
|
|
170
|
+
if (row.tool_auth) {
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(row.tool_auth);
|
|
173
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length) {
|
|
174
|
+
chat.toolAuth = parsed;
|
|
175
|
+
}
|
|
176
|
+
} catch { /* keep undefined */ }
|
|
177
|
+
}
|
|
178
|
+
// Drop undefined tools so the caller can distinguish "not set" from "empty array"
|
|
179
|
+
if (chat.tools === undefined) delete chat.tools;
|
|
180
|
+
return chat;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- Chat list projection -------------------------------------------------
|
|
184
|
+
//
|
|
185
|
+
// `chat_store` has two unbounded TEXT columns: `draft` (whatever the composer
|
|
186
|
+
// holds) and `draft_attachments` (the pending image draft — up to 8 base64
|
|
187
|
+
// data URLs, each bounded at 12 MB by frontend/src/components/chat/annotation.js).
|
|
188
|
+
// A `SELECT *` list therefore reads those bodies out of SQLite, JSON-parses
|
|
189
|
+
// them, and ships them for every row of the page, although the chat list only
|
|
190
|
+
// needs a one-line preview of the draft. With an image in a draft that is
|
|
191
|
+
// megabytes per row — the difference between an instant Chats tab and a
|
|
192
|
+
// multi-second one.
|
|
193
|
+
//
|
|
194
|
+
// The list query therefore never touches the attachment body. It reads:
|
|
195
|
+
// - `draft_snippet` — a bounded head of the text draft, for the card preview;
|
|
196
|
+
// - `has_draft_image` — whether `draft_attachments` is non-NULL. SQLite
|
|
197
|
+
// answers `IS NOT NULL` from the record header (OPFLAG_TYPEOFARG), so the
|
|
198
|
+
// JSON body is never materialized.
|
|
199
|
+
// The full `draft` / `draftAttachments` pair still comes back from getChat() /
|
|
200
|
+
// GET /api/chats/:id, which is what the composer restores from.
|
|
201
|
+
const DRAFT_SNIPPET_CHARS = 400;
|
|
202
|
+
|
|
203
|
+
const LIST_COLUMNS = `
|
|
204
|
+
project_dir, id, title, created_at, last_opened_at, trace, prompt_size,
|
|
205
|
+
prompt_id, provider_id, model_id, thinking_level, max_output_tokens,
|
|
206
|
+
substr(draft, 1, ${DRAFT_SNIPPET_CHARS}) AS draft_snippet,
|
|
207
|
+
(draft_attachments IS NOT NULL) AS has_draft_image,
|
|
208
|
+
tools, agent_id, agent_files, skills, auto_retry,
|
|
209
|
+
total_cost, cost_known_count
|
|
210
|
+
`;
|
|
211
|
+
|
|
212
|
+
// rowToChatSummary(row) -> chat list entry.
|
|
213
|
+
//
|
|
214
|
+
// Same shape as rowToChat() minus the two unbounded columns: `draft` is
|
|
215
|
+
// replaced by `draftSnippet` and `hasDraftImage` reports whether an image
|
|
216
|
+
// draft exists without transferring it. Anything that needs the whole draft
|
|
217
|
+
// must call getChat().
|
|
218
|
+
function rowToChatSummary(row) {
|
|
219
|
+
if (!row) return null;
|
|
220
|
+
const chat = rowToChat(Object.assign({}, row, { draft: '', draft_attachments: null }));
|
|
221
|
+
delete chat.draft;
|
|
222
|
+
chat.draftSnippet = typeof row.draft_snippet === 'string' ? row.draft_snippet : '';
|
|
223
|
+
chat.hasDraftImage = row.has_draft_image === 1;
|
|
224
|
+
return chat;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function chatToRow(projectDir, chat) {
|
|
228
|
+
return {
|
|
229
|
+
project_dir: projectDir,
|
|
230
|
+
id: chat.id,
|
|
231
|
+
title: chat.title || 'New chat',
|
|
232
|
+
created_at: chat.createdAt || new Date().toISOString(),
|
|
233
|
+
last_opened_at: chat.lastOpenedAt || null,
|
|
234
|
+
trace: chat.trace ? 1 : 0,
|
|
235
|
+
prompt_size: chat.promptSize || 'average',
|
|
236
|
+
prompt_id: chat.promptId || null,
|
|
237
|
+
provider_id: chat.providerId || null,
|
|
238
|
+
model_id: chat.modelId || null,
|
|
239
|
+
thinking_level: chat.thinkingLevel || '',
|
|
240
|
+
max_output_tokens: chat.maxOutputTokens || '',
|
|
241
|
+
draft: chat.draft || '',
|
|
242
|
+
draft_attachments: (chat.draftAttachments === undefined || chat.draftAttachments === null) ? null : JSON.stringify(chat.draftAttachments),
|
|
243
|
+
tools: chat.tools === undefined ? null : JSON.stringify(chat.tools),
|
|
244
|
+
// agent_id is a legacy column from the removed chat-persona design.
|
|
245
|
+
// It stays in the schema for old DBs but is always written as null.
|
|
246
|
+
agent_id: null,
|
|
247
|
+
agent_files: chat.agentFiles === undefined ? null : (chat.agentFiles ? 1 : 0),
|
|
248
|
+
skills: chat.skills === undefined ? null : (chat.skills ? 1 : 0),
|
|
249
|
+
// An empty list is written as NULL so "no per-skill opt-outs" has a
|
|
250
|
+
// single representation in the store instead of two.
|
|
251
|
+
disabled_skills: (Array.isArray(chat.disabledSkills) && chat.disabledSkills.length)
|
|
252
|
+
? JSON.stringify(chat.disabledSkills.map((n) => String(n)).filter(Boolean))
|
|
253
|
+
: null,
|
|
254
|
+
// Same single-representation rule as disabled_skills: "no per-chat
|
|
255
|
+
// overrides" is NULL, never "{}".
|
|
256
|
+
tool_auth: (chat.toolAuth && typeof chat.toolAuth === 'object' && !Array.isArray(chat.toolAuth) && Object.keys(chat.toolAuth).length)
|
|
257
|
+
? JSON.stringify(chat.toolAuth)
|
|
258
|
+
: null,
|
|
259
|
+
auto_retry: chat.autoRetry === undefined ? 1 : (chat.autoRetry ? 1 : 0),
|
|
260
|
+
total_cost: chat.totalCost && typeof chat.totalCost.total === 'number' ? chat.totalCost.total : 0,
|
|
261
|
+
cost_known_count: chat.totalCost && chat.totalCost.known
|
|
262
|
+
? (Number.isInteger(chat.totalCost.knownCount) ? chat.totalCost.knownCount : 1)
|
|
263
|
+
: 0
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function rowToMessage(row) {
|
|
268
|
+
if (!row) return null;
|
|
269
|
+
// seq is the row's stable, monotonically increasing position within
|
|
270
|
+
// this (project_dir, chat_id). It is the ONLY identity the client
|
|
271
|
+
// can reliably dedup on: the SQLite backend assigns it at insert
|
|
272
|
+
// (append-only), so a row kept its seq forever. The client merges
|
|
273
|
+
// by seq and never re-adds a row it already holds.
|
|
274
|
+
const msg = { role: row.role, content: row.content, ts: row.ts, seq: row.seq };
|
|
275
|
+
if (row.role === 'user' && row.attachments) {
|
|
276
|
+
try { msg.attachments = JSON.parse(row.attachments); } catch { /* ignore */ }
|
|
277
|
+
}
|
|
278
|
+
if (row.role === 'assistant') {
|
|
279
|
+
if (row.reasoning) msg.reasoning = row.reasoning;
|
|
280
|
+
if (row.usage) try { msg.usage = JSON.parse(row.usage); } catch { /* ignore */ }
|
|
281
|
+
if (row.cost) try { msg.cost = JSON.parse(row.cost); } catch { /* ignore */ }
|
|
282
|
+
if (row.streaming_ms != null) msg.streamingMs = row.streaming_ms;
|
|
283
|
+
if (row.model_id) msg.modelId = row.model_id;
|
|
284
|
+
}
|
|
285
|
+
if (row.role === 'tool') {
|
|
286
|
+
if (row.tool_call_id) msg.toolCallId = row.tool_call_id;
|
|
287
|
+
if (row.name) msg.name = row.name;
|
|
288
|
+
if (row.args) try { msg.args = JSON.parse(row.args); } catch { /* ignore */ }
|
|
289
|
+
if (row.ok != null) msg.ok = row.ok === 1;
|
|
290
|
+
if (row.phase) msg.phase = row.phase;
|
|
291
|
+
}
|
|
292
|
+
return msg;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function messageToRow(projectDir, chatId, seq, msg) {
|
|
296
|
+
return {
|
|
297
|
+
project_dir: projectDir,
|
|
298
|
+
chat_id: chatId,
|
|
299
|
+
seq,
|
|
300
|
+
role: msg.role,
|
|
301
|
+
content: msg.content,
|
|
302
|
+
ts: msg.ts || new Date().toISOString(),
|
|
303
|
+
reasoning: msg.role === 'assistant' ? (msg.reasoning || null) : null,
|
|
304
|
+
usage: msg.role === 'assistant' && msg.usage ? JSON.stringify(msg.usage) : null,
|
|
305
|
+
cost: msg.role === 'assistant' && msg.cost ? JSON.stringify(msg.cost) : null,
|
|
306
|
+
streaming_ms: msg.role === 'assistant' && msg.streamingMs != null ? msg.streamingMs : null,
|
|
307
|
+
model_id: msg.role === 'assistant' && msg.modelId ? msg.modelId : null,
|
|
308
|
+
attachments: msg.role === 'user' && msg.attachments ? JSON.stringify(msg.attachments) : null,
|
|
309
|
+
tool_call_id: msg.role === 'tool' && msg.toolCallId ? msg.toolCallId : null,
|
|
310
|
+
name: msg.role === 'tool' && msg.name ? msg.name : null,
|
|
311
|
+
args: msg.role === 'tool' && msg.args ? JSON.stringify(msg.args) : null,
|
|
312
|
+
ok: msg.role === 'tool' && msg.ok != null ? (msg.ok ? 1 : 0) : null,
|
|
313
|
+
phase: msg.role === 'tool' && msg.phase ? msg.phase : null
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---- Chat CRUD ------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
function listChats(projectDir, options = {}) {
|
|
320
|
+
ensureTables();
|
|
321
|
+
const d = require('./settings.js').getDb();
|
|
322
|
+
const offset = Number.isInteger(options.offset) && options.offset > 0 ? options.offset : 0;
|
|
323
|
+
const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 0;
|
|
324
|
+
const order = 'ORDER BY COALESCE(last_opened_at, created_at) DESC, id DESC';
|
|
325
|
+
// List rows are summaries, never the draft bodies: see LIST_COLUMNS.
|
|
326
|
+
const rows = limit > 0
|
|
327
|
+
? d.prepare(`SELECT ${LIST_COLUMNS} FROM chat_store WHERE project_dir = ? ${order} LIMIT ? OFFSET ?`)
|
|
328
|
+
.all(projectDir, limit, offset)
|
|
329
|
+
: d.prepare(`SELECT ${LIST_COLUMNS} FROM chat_store WHERE project_dir = ? ${order}`).all(projectDir);
|
|
330
|
+
return rows.map(rowToChatSummary);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function countChats(projectDir) {
|
|
334
|
+
ensureTables();
|
|
335
|
+
const d = require('./settings.js').getDb();
|
|
336
|
+
const row = d.prepare('SELECT COUNT(*) AS total FROM chat_store WHERE project_dir = ?').get(projectDir);
|
|
337
|
+
return row ? row.total : 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function getChat(projectDir, chatId) {
|
|
341
|
+
ensureTables();
|
|
342
|
+
const d = require('./settings.js').getDb();
|
|
343
|
+
const row = d.prepare(
|
|
344
|
+
'SELECT * FROM chat_store WHERE project_dir = ? AND id = ?'
|
|
345
|
+
).get(projectDir, chatId);
|
|
346
|
+
return rowToChat(row);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function createChat(projectDir, chat) {
|
|
350
|
+
ensureTables();
|
|
351
|
+
const d = require('./settings.js').getDb();
|
|
352
|
+
const row = chatToRow(projectDir, chat);
|
|
353
|
+
d.prepare(`
|
|
354
|
+
INSERT INTO chat_store (project_dir, id, title, created_at, last_opened_at,
|
|
355
|
+
trace, prompt_size, prompt_id, provider_id, model_id, thinking_level, max_output_tokens, draft, draft_attachments, tools,
|
|
356
|
+
agent_id, agent_files, skills, disabled_skills, tool_auth, auto_retry, total_cost, cost_known_count)
|
|
357
|
+
VALUES (@project_dir, @id, @title, @created_at, @last_opened_at,
|
|
358
|
+
@trace, @prompt_size, @prompt_id, @provider_id, @model_id, @thinking_level, @max_output_tokens, @draft, @draft_attachments, @tools,
|
|
359
|
+
@agent_id, @agent_files, @skills, @disabled_skills, @tool_auth, @auto_retry, @total_cost, @cost_known_count)
|
|
360
|
+
`).run(row);
|
|
361
|
+
return rowToChat(d.prepare(
|
|
362
|
+
'SELECT * FROM chat_store WHERE project_dir = ? AND id = ?'
|
|
363
|
+
).get(projectDir, chat.id));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function updateChat(projectDir, chatId, patch) {
|
|
367
|
+
ensureTables();
|
|
368
|
+
const d = require('./settings.js').getDb();
|
|
369
|
+
const existing = d.prepare(
|
|
370
|
+
'SELECT * FROM chat_store WHERE project_dir = ? AND id = ?'
|
|
371
|
+
).get(projectDir, chatId);
|
|
372
|
+
if (!existing) return null;
|
|
373
|
+
|
|
374
|
+
const merged = Object.assign({}, rowToChat(existing), patch);
|
|
375
|
+
const row = chatToRow(projectDir, merged);
|
|
376
|
+
d.prepare(`
|
|
377
|
+
UPDATE chat_store SET
|
|
378
|
+
title = @title, last_opened_at = @last_opened_at,
|
|
379
|
+
trace = @trace, prompt_size = @prompt_size,
|
|
380
|
+
prompt_id = @prompt_id, provider_id = @provider_id,
|
|
381
|
+
model_id = @model_id, thinking_level = @thinking_level,
|
|
382
|
+
max_output_tokens = @max_output_tokens,
|
|
383
|
+
draft = @draft, draft_attachments = @draft_attachments, tools = @tools,
|
|
384
|
+
agent_id = @agent_id, agent_files = @agent_files,
|
|
385
|
+
skills = @skills, disabled_skills = @disabled_skills,
|
|
386
|
+
tool_auth = @tool_auth,
|
|
387
|
+
auto_retry = @auto_retry, total_cost = @total_cost,
|
|
388
|
+
cost_known_count = @cost_known_count
|
|
389
|
+
WHERE project_dir = @project_dir AND id = @id
|
|
390
|
+
`).run(row);
|
|
391
|
+
return rowToChat(d.prepare(
|
|
392
|
+
'SELECT * FROM chat_store WHERE project_dir = ? AND id = ?'
|
|
393
|
+
).get(projectDir, chatId));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function deleteChat(projectDir, chatId) {
|
|
397
|
+
ensureTables();
|
|
398
|
+
const d = require('./settings.js').getDb();
|
|
399
|
+
const previous = readChatCostRow(d, projectDir, chatId);
|
|
400
|
+
const tx = d.transaction(() => {
|
|
401
|
+
const info = d.prepare(
|
|
402
|
+
'DELETE FROM chat_store WHERE project_dir = ? AND id = ?'
|
|
403
|
+
).run(projectDir, chatId);
|
|
404
|
+
d.prepare(
|
|
405
|
+
'DELETE FROM message_store WHERE project_dir = ? AND chat_id = ?'
|
|
406
|
+
).run(projectDir, chatId);
|
|
407
|
+
if (info.changes > 0 && (previous.total || previous.knownCount)) {
|
|
408
|
+
require('./projects.js').adjustProjectTotalCost(projectDir, -previous.total, -previous.knownCount);
|
|
409
|
+
}
|
|
410
|
+
return info.changes > 0;
|
|
411
|
+
});
|
|
412
|
+
return tx();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---- Message CRUD ---------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
function knownCostDelta(msg) {
|
|
418
|
+
if (!msg || msg.role !== 'assistant' || !msg.cost || msg.cost.known !== true) return null;
|
|
419
|
+
const total = msg.cost.total;
|
|
420
|
+
if (typeof total !== 'number' || !Number.isFinite(total) || total < 0) return null;
|
|
421
|
+
return total;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function readChatCostRow(d, projectDir, chatId) {
|
|
425
|
+
const row = d.prepare(
|
|
426
|
+
'SELECT total_cost, cost_known_count FROM chat_store WHERE project_dir = ? AND id = ?'
|
|
427
|
+
).get(projectDir, chatId);
|
|
428
|
+
return {
|
|
429
|
+
total: row && typeof row.total_cost === 'number' ? row.total_cost : 0,
|
|
430
|
+
knownCount: row && typeof row.cost_known_count === 'number' ? row.cost_known_count : 0
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function listMessages(projectDir, chatId) {
|
|
435
|
+
ensureTables();
|
|
436
|
+
const d = require('./settings.js').getDb();
|
|
437
|
+
const rows = d.prepare(
|
|
438
|
+
'SELECT * FROM message_store WHERE project_dir = ? AND chat_id = ? ORDER BY seq ASC'
|
|
439
|
+
).all(projectDir, chatId);
|
|
440
|
+
return rows.map(rowToMessage);
|
|
441
|
+
}
|
|
442
|
+
// listMessagesWindow(projectDir, chatId, opts) -> Array<Message>
|
|
443
|
+
//
|
|
444
|
+
// Windowed backward fetch for chat pagination. Returns the `limit`
|
|
445
|
+
// messages at or just BELOW `beforeSeq` in chronological order. Used
|
|
446
|
+
// by the transcript's scroll-up loader so opening a long chat only
|
|
447
|
+
// transfers the newest page; older pages are fetched on demand as the
|
|
448
|
+
// user scrolls up. The `seq` is the stable, monotonic per-chat row id —
|
|
449
|
+
// the same identity the tail sync uses — so the window and the
|
|
450
|
+
// append-only tail never disagree.
|
|
451
|
+
//
|
|
452
|
+
// opts = { limit, beforeSeq } (limit defaults to 100; beforeSeq is the
|
|
453
|
+
// exclusive upper bound, defaulting to "everything")
|
|
454
|
+
function listMessagesWindow(projectDir, chatId, opts) {
|
|
455
|
+
ensureTables();
|
|
456
|
+
const d = require('./settings.js').getDb();
|
|
457
|
+
const limitRaw = Number.isInteger(opts && opts.limit) && (opts.limit > 0) ? opts.limit : 100;
|
|
458
|
+
const beforeSeq = Number.isInteger(opts && opts.beforeSeq) && opts.beforeSeq >= 0 ? opts.beforeSeq : Infinity;
|
|
459
|
+
// Fetch `limit` rows strictly below beforeSeq, ordered newest-last in
|
|
460
|
+
// SQL then reversed so the returned array runs oldest -> newest within
|
|
461
|
+
// the window. Without a beforeSeq (the first page) we want the newest
|
|
462
|
+
// rows overall: ORDER BY seq DESC LIMIT n.
|
|
463
|
+
const rows = d.prepare(
|
|
464
|
+
'SELECT * FROM message_store WHERE project_dir = ? AND chat_id = ? AND seq < ? ORDER BY seq DESC LIMIT ?'
|
|
465
|
+
).all(projectDir, chatId, beforeSeq, limitRaw);
|
|
466
|
+
rows.reverse();
|
|
467
|
+
return rows.map(rowToMessage);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function appendMessage(projectDir, chatId, msg) {
|
|
471
|
+
ensureTables();
|
|
472
|
+
const d = require('./settings.js').getDb();
|
|
473
|
+
const maxSeq = d.prepare(
|
|
474
|
+
'SELECT COALESCE(MAX(seq), -1) AS max_seq FROM message_store WHERE project_dir = ? AND chat_id = ?'
|
|
475
|
+
).get(projectDir, chatId);
|
|
476
|
+
const seq = (maxSeq ? maxSeq.max_seq : -1) + 1;
|
|
477
|
+
const row = messageToRow(projectDir, chatId, seq, msg);
|
|
478
|
+
const costDelta = knownCostDelta(msg);
|
|
479
|
+
const tx = d.transaction(() => {
|
|
480
|
+
d.prepare(`
|
|
481
|
+
INSERT INTO message_store (project_dir, chat_id, seq, role, content, ts,
|
|
482
|
+
reasoning, usage, cost, streaming_ms, model_id, attachments,
|
|
483
|
+
tool_call_id, name, args, ok, phase)
|
|
484
|
+
VALUES (@project_dir, @chat_id, @seq, @role, @content, @ts,
|
|
485
|
+
@reasoning, @usage, @cost, @streaming_ms, @model_id, @attachments,
|
|
486
|
+
@tool_call_id, @name, @args, @ok, @phase)
|
|
487
|
+
`).run(row);
|
|
488
|
+
if (costDelta !== null) {
|
|
489
|
+
const updated = d.prepare(`
|
|
490
|
+
UPDATE chat_store
|
|
491
|
+
SET total_cost = total_cost + ?, cost_known_count = cost_known_count + 1
|
|
492
|
+
WHERE project_dir = ? AND id = ?
|
|
493
|
+
`).run(costDelta, projectDir, chatId);
|
|
494
|
+
if (updated.changes !== 1) throw new Error('Chat not found while adding message cost');
|
|
495
|
+
require('./projects.js').adjustProjectTotalCost(projectDir, costDelta, 1);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
tx();
|
|
499
|
+
return rowToMessage(row);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function replaceMessages(projectDir, chatId, list) {
|
|
503
|
+
ensureTables();
|
|
504
|
+
const d = require('./settings.js').getDb();
|
|
505
|
+
const previous = readChatCostRow(d, projectDir, chatId);
|
|
506
|
+
let nextTotal = 0;
|
|
507
|
+
let nextKnownCount = 0;
|
|
508
|
+
for (const msg of list) {
|
|
509
|
+
const cost = knownCostDelta(msg);
|
|
510
|
+
if (cost !== null) {
|
|
511
|
+
nextTotal += cost;
|
|
512
|
+
nextKnownCount++;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
const totalDelta = nextTotal - previous.total;
|
|
516
|
+
const knownCountDelta = nextKnownCount - previous.knownCount;
|
|
517
|
+
const tx = d.transaction(() => {
|
|
518
|
+
d.prepare('DELETE FROM message_store WHERE project_dir = ? AND chat_id = ?').run(projectDir, chatId);
|
|
519
|
+
const insert = d.prepare(`
|
|
520
|
+
INSERT INTO message_store (project_dir, chat_id, seq, role, content, ts,
|
|
521
|
+
reasoning, usage, cost, streaming_ms, model_id, attachments,
|
|
522
|
+
tool_call_id, name, args, ok, phase)
|
|
523
|
+
VALUES (@project_dir, @chat_id, @seq, @role, @content, @ts,
|
|
524
|
+
@reasoning, @usage, @cost, @streaming_ms, @model_id, @attachments,
|
|
525
|
+
@tool_call_id, @name, @args, @ok, @phase)
|
|
526
|
+
`);
|
|
527
|
+
for (let i = 0; i < list.length; i++) {
|
|
528
|
+
insert.run(messageToRow(projectDir, chatId, i, list[i]));
|
|
529
|
+
}
|
|
530
|
+
d.prepare(`
|
|
531
|
+
UPDATE chat_store SET total_cost = ?, cost_known_count = ?
|
|
532
|
+
WHERE project_dir = ? AND id = ?
|
|
533
|
+
`).run(nextTotal, nextKnownCount, projectDir, chatId);
|
|
534
|
+
if (totalDelta || knownCountDelta) {
|
|
535
|
+
require('./projects.js').adjustProjectTotalCost(projectDir, totalDelta, knownCountDelta);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
tx();
|
|
539
|
+
return list.map((m, i) => rowToMessage(
|
|
540
|
+
d.prepare('SELECT * FROM message_store WHERE project_dir = ? AND chat_id = ? AND seq = ?').get(projectDir, chatId, i)
|
|
541
|
+
));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function clearMessages(projectDir, chatId) {
|
|
545
|
+
ensureTables();
|
|
546
|
+
const d = require('./settings.js').getDb();
|
|
547
|
+
const previous = readChatCostRow(d, projectDir, chatId);
|
|
548
|
+
const tx = d.transaction(() => {
|
|
549
|
+
const info = d.prepare(
|
|
550
|
+
'DELETE FROM message_store WHERE project_dir = ? AND chat_id = ?'
|
|
551
|
+
).run(projectDir, chatId);
|
|
552
|
+
d.prepare(`
|
|
553
|
+
UPDATE chat_store SET total_cost = 0, cost_known_count = 0
|
|
554
|
+
WHERE project_dir = ? AND id = ?
|
|
555
|
+
`).run(projectDir, chatId);
|
|
556
|
+
if (previous.total || previous.knownCount) {
|
|
557
|
+
require('./projects.js').adjustProjectTotalCost(projectDir, -previous.total, -previous.knownCount);
|
|
558
|
+
}
|
|
559
|
+
return info.changes;
|
|
560
|
+
});
|
|
561
|
+
return tx();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function getMessageCount(projectDir, chatId) {
|
|
565
|
+
ensureTables();
|
|
566
|
+
const d = require('./settings.js').getDb();
|
|
567
|
+
const row = d.prepare(
|
|
568
|
+
'SELECT COUNT(*) AS count FROM message_store WHERE project_dir = ? AND chat_id = ?'
|
|
569
|
+
).get(projectDir, chatId);
|
|
570
|
+
return row ? row.count : 0;
|
|
571
|
+
}
|
|
572
|
+
// projectMessageCounts(projectDir, chatIds) -> { [chatId]: count }
|
|
573
|
+
//
|
|
574
|
+
// Bulk row count for a set of chats in one indexed GROUP BY (the chat list
|
|
575
|
+
// renders 30–100 rows at a time, so a per-chat COUNT would be too many
|
|
576
|
+
// queries). The count is what the project card uses to flag "draft-only"
|
|
577
|
+
// chats — those with zero persisted messages. Rides the
|
|
578
|
+
// (project_dir, chat_id, seq) index, so it never scans a transcript.
|
|
579
|
+
function projectMessageCounts(projectDir, chatIds) {
|
|
580
|
+
ensureTables();
|
|
581
|
+
const d = require('./settings.js').getDb();
|
|
582
|
+
const ids = Array.isArray(chatIds)
|
|
583
|
+
? [...new Set(chatIds.filter((id) => typeof id === 'string' && id))]
|
|
584
|
+
: null;
|
|
585
|
+
if (ids && ids.length === 0) return {};
|
|
586
|
+
const idFilter = ids ? ' AND chat_id IN (' + ids.map(() => '?').join(',') + ')' : '';
|
|
587
|
+
const rows = d.prepare(
|
|
588
|
+
'SELECT chat_id, COUNT(*) AS count FROM message_store WHERE project_dir = ?' + idFilter + ' GROUP BY chat_id'
|
|
589
|
+
).all(projectDir, ...(ids || []));
|
|
590
|
+
const out = {};
|
|
591
|
+
for (const r of rows) out[r.chat_id] = r.count;
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// messageCursorDb(projectDir, chatId) -> { nextSeq }
|
|
596
|
+
//
|
|
597
|
+
// Cheap append-only recovery cursor: the next persisted seq for this
|
|
598
|
+
// chat. Derive it from MAX(seq)+1 rather than COUNT(*) so the cursor
|
|
599
|
+
// stays monotonic even if seq ever becomes non-contiguous (e.g. a
|
|
600
|
+
// future delete/reinsert path); the client treats `nextSeq` as a seq
|
|
601
|
+
// cursor, not a row count.
|
|
602
|
+
function messageCursorDb(projectDir, chatId) {
|
|
603
|
+
ensureTables();
|
|
604
|
+
const d = require('./settings.js').getDb();
|
|
605
|
+
const row = d.prepare(
|
|
606
|
+
'SELECT COALESCE(MAX(seq), -1) + 1 AS nextSeq FROM message_store WHERE project_dir = ? AND chat_id = ?'
|
|
607
|
+
).get(projectDir, chatId);
|
|
608
|
+
return { nextSeq: row && typeof row.nextSeq === 'number' ? row.nextSeq : 0 };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---- Cost aggregation (SQL, no full-transcript reads) ---------------------
|
|
612
|
+
|
|
613
|
+
// One aggregate row per selected chat (or every project chat when no IDs
|
|
614
|
+
// are supplied): the known assistant-turn cost total and whether any known
|
|
615
|
+
// cost exists. Matches the JS loop in chats.chatTotalCost exactly
|
|
616
|
+
// (cost.known === true && total >= 0), but uses one indexed aggregate instead
|
|
617
|
+
// of one full listMessages call per chat.
|
|
618
|
+
// json_extract is a SQLite JSON1 builtin — present in every Node.js
|
|
619
|
+
// better-sqlite3 build (verified).
|
|
620
|
+
function projectCostTotals(projectDir, chatIds) {
|
|
621
|
+
ensureTables();
|
|
622
|
+
const d = require('./settings.js').getDb();
|
|
623
|
+
const ids = Array.isArray(chatIds)
|
|
624
|
+
? [...new Set(chatIds.filter((id) => typeof id === 'string' && id))]
|
|
625
|
+
: null;
|
|
626
|
+
if (ids && ids.length === 0) return {};
|
|
627
|
+
const idFilter = ids ? ` AND chat_id IN (${ids.map(() => '?').join(',')})` : '';
|
|
628
|
+
const rows = d.prepare(`
|
|
629
|
+
SELECT chat_id,
|
|
630
|
+
SUM(CASE WHEN json_extract(cost, '$.known') = 1
|
|
631
|
+
AND CAST(json_extract(cost, '$.total') AS REAL) >= 0
|
|
632
|
+
THEN CAST(json_extract(cost, '$.total') AS REAL) ELSE 0 END) AS total,
|
|
633
|
+
MAX(CASE WHEN json_extract(cost, '$.known') = 1
|
|
634
|
+
AND CAST(json_extract(cost, '$.total') AS REAL) >= 0
|
|
635
|
+
THEN 1 ELSE 0 END) AS known,
|
|
636
|
+
SUM(CASE WHEN json_extract(cost, '$.known') = 1
|
|
637
|
+
AND CAST(json_extract(cost, '$.total') AS REAL) >= 0
|
|
638
|
+
THEN 1 ELSE 0 END) AS known_count
|
|
639
|
+
FROM message_store
|
|
640
|
+
WHERE project_dir = ?
|
|
641
|
+
AND role = 'assistant'
|
|
642
|
+
AND cost IS NOT NULL${idFilter}
|
|
643
|
+
GROUP BY chat_id
|
|
644
|
+
`).all(projectDir, ...(ids || []));
|
|
645
|
+
const out = {};
|
|
646
|
+
for (const r of rows) {
|
|
647
|
+
out[r.chat_id] = {
|
|
648
|
+
total: typeof r.total === 'number' ? r.total : 0,
|
|
649
|
+
known: r.known === 1,
|
|
650
|
+
knownCount: typeof r.known_count === 'number' ? r.known_count : 0
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
return out;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function chatTotalCostDb(projectDir, chatId) {
|
|
657
|
+
ensureTables();
|
|
658
|
+
const d = require('./settings.js').getDb();
|
|
659
|
+
const row = d.prepare(`
|
|
660
|
+
SELECT COALESCE(SUM(CASE WHEN role = 'assistant'
|
|
661
|
+
AND cost IS NOT NULL
|
|
662
|
+
AND json_extract(cost, '$.known') = 1
|
|
663
|
+
AND CAST(json_extract(cost, '$.total') AS REAL) >= 0
|
|
664
|
+
THEN CAST(json_extract(cost, '$.total') AS REAL) ELSE 0 END), 0) AS total,
|
|
665
|
+
MAX(CASE WHEN role = 'assistant'
|
|
666
|
+
AND cost IS NOT NULL
|
|
667
|
+
AND json_extract(cost, '$.known') = 1
|
|
668
|
+
AND CAST(json_extract(cost, '$.total') AS REAL) >= 0
|
|
669
|
+
THEN 1 ELSE 0 END) AS known
|
|
670
|
+
FROM message_store
|
|
671
|
+
WHERE project_dir = ? AND chat_id = ?
|
|
672
|
+
`).get(projectDir, chatId);
|
|
673
|
+
return {
|
|
674
|
+
total: typeof row.total === 'number' ? row.total : 0,
|
|
675
|
+
known: row.known === 1
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// addChatCost(projectDir, chatId, amount) -> { total, known, currency, knownCount } | null
|
|
680
|
+
//
|
|
681
|
+
// Attribute one *known* priced run to a chat's persisted total without writing
|
|
682
|
+
// a message row. This is the second writer of `total_cost` /
|
|
683
|
+
// `cost_known_count` (appendMessage is the first) and it exists because
|
|
684
|
+
// dictation is billed work that is not a chat turn: the transcription's cost
|
|
685
|
+
// belongs in the chat and project totals, but no assistant message carries it
|
|
686
|
+
// (docs/features/dictation.md).
|
|
687
|
+
//
|
|
688
|
+
// The chat row and the registered project's total move together, in one
|
|
689
|
+
// transaction, exactly like the message path — the two must never disagree.
|
|
690
|
+
// Returns null when the chat does not exist, so a caller that has already
|
|
691
|
+
// performed (and billed) the upstream call can ignore attribution instead of
|
|
692
|
+
// failing the run.
|
|
693
|
+
function addChatCost(projectDir, chatId, amount) {
|
|
694
|
+
const delta = Number(amount);
|
|
695
|
+
if (!projectDir || !chatId || !Number.isFinite(delta) || delta < 0) return null;
|
|
696
|
+
ensureTables();
|
|
697
|
+
const d = require('./settings.js').getDb();
|
|
698
|
+
let updated = false;
|
|
699
|
+
const tx = d.transaction(() => {
|
|
700
|
+
const info = d.prepare(`
|
|
701
|
+
UPDATE chat_store
|
|
702
|
+
SET total_cost = total_cost + ?, cost_known_count = cost_known_count + 1
|
|
703
|
+
WHERE project_dir = ? AND id = ?
|
|
704
|
+
`).run(delta, projectDir, chatId);
|
|
705
|
+
if (info.changes !== 1) return;
|
|
706
|
+
require('./projects.js').adjustProjectTotalCost(projectDir, delta, 1);
|
|
707
|
+
updated = true;
|
|
708
|
+
});
|
|
709
|
+
tx();
|
|
710
|
+
if (!updated) return null;
|
|
711
|
+
const row = readChatCostRow(d, projectDir, chatId);
|
|
712
|
+
return {
|
|
713
|
+
total: row.total,
|
|
714
|
+
known: row.knownCount > 0,
|
|
715
|
+
currency: 'USD',
|
|
716
|
+
knownCount: row.knownCount
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ---- Import from JSON files ------------------------------------------------
|
|
721
|
+
//
|
|
722
|
+
// Legacy one-shot import from `.mouaif.messages.*.json` transcripts and the
|
|
723
|
+
// old `<projectDir>/.mouaif.json` `chats` array. Kept so users who ran the
|
|
724
|
+
// earlier file-based storage can migrate that history into the DB; it is
|
|
725
|
+
// no longer an active backend and the import migration is retired.
|
|
726
|
+
|
|
727
|
+
function importFromJson(projectDir, opts) {
|
|
728
|
+
ensureTables();
|
|
729
|
+
const fs = require('fs');
|
|
730
|
+
const settings = require('./settings.js');
|
|
731
|
+
const chatsFile = settings.getProjectPath(projectDir);
|
|
732
|
+
const imported = { chats: 0, messages: 0, errors: [] };
|
|
733
|
+
|
|
734
|
+
let project;
|
|
735
|
+
try {
|
|
736
|
+
project = settings.readProjectJson(chatsFile);
|
|
737
|
+
} catch (e) {
|
|
738
|
+
imported.errors.push('Failed to read ' + chatsFile + ': ' + e.message);
|
|
739
|
+
return imported;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const rawChats = Array.isArray(project.chats) ? project.chats : [];
|
|
743
|
+
for (const chat of rawChats) {
|
|
744
|
+
if (!chat || !chat.id || !CHAT_ID_RE.test(chat.id)) continue;
|
|
745
|
+
try {
|
|
746
|
+
const skipExisting = opts && opts.skipExisting;
|
|
747
|
+
if (skipExisting && getChat(projectDir, chat.id)) {
|
|
748
|
+
// Chat already in DB; still import messages if missing.
|
|
749
|
+
if (getMessageCount(projectDir, chat.id) === 0) {
|
|
750
|
+
importMessagesFromJson(projectDir, chat.id, imported);
|
|
751
|
+
}
|
|
752
|
+
imported.chats++;
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
createChat(projectDir, chat);
|
|
756
|
+
imported.chats++;
|
|
757
|
+
importMessagesFromJson(projectDir, chat.id, imported);
|
|
758
|
+
} catch (e) {
|
|
759
|
+
imported.errors.push('Chat ' + chat.id + ': ' + e.message);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return imported;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function importMessagesFromJson(projectDir, chatId, imported) {
|
|
767
|
+
const fs = require('fs');
|
|
768
|
+
const settings = require('./settings.js');
|
|
769
|
+
const messagesPath = path.join(projectDir, '.mouaif.messages.' + chatId + '.json');
|
|
770
|
+
if (!fs.existsSync(messagesPath)) return;
|
|
771
|
+
try {
|
|
772
|
+
const raw = settings.readProjectJson(messagesPath);
|
|
773
|
+
const list = Array.isArray(raw.messages) ? raw.messages : [];
|
|
774
|
+
const normalized = [];
|
|
775
|
+
for (const m of list) {
|
|
776
|
+
const n = normalizeMessage(m);
|
|
777
|
+
if (n) normalized.push(n);
|
|
778
|
+
}
|
|
779
|
+
if (normalized.length === 0) return;
|
|
780
|
+
if (getMessageCount(projectDir, chatId) >= normalized.length) {
|
|
781
|
+
imported.messages += normalized.length;
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
replaceMessages(projectDir, chatId, normalized);
|
|
785
|
+
imported.messages += normalized.length;
|
|
786
|
+
} catch (e) {
|
|
787
|
+
imported.errors.push('Messages ' + chatId + ': ' + e.message);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
module.exports = {
|
|
792
|
+
// Chat CRUD
|
|
793
|
+
listChats,
|
|
794
|
+
countChats,
|
|
795
|
+
getChat,
|
|
796
|
+
createChat,
|
|
797
|
+
updateChat,
|
|
798
|
+
deleteChat,
|
|
799
|
+
// Message CRUD
|
|
800
|
+
listMessages,
|
|
801
|
+
listMessagesWindow,
|
|
802
|
+
appendMessage,
|
|
803
|
+
replaceMessages,
|
|
804
|
+
clearMessages,
|
|
805
|
+
getMessageCount,
|
|
806
|
+
projectMessageCounts,
|
|
807
|
+
messageCursorDb,
|
|
808
|
+
// Cost aggregation
|
|
809
|
+
projectCostTotals,
|
|
810
|
+
chatTotalCostDb,
|
|
811
|
+
addChatCost,
|
|
812
|
+
// Legacy import
|
|
813
|
+
importFromJson,
|
|
814
|
+
// Exported so settings migrations can create the tables before their ALTERs.
|
|
815
|
+
ensureChatTables: ensureTables
|
|
816
|
+
};
|