claude-master-toolkit 0.1.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/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/cli.js +677 -0
- package/dist/public/assets/index-aH43pgAv.css +1 -0
- package/dist/public/assets/index-lE5fsDTh.js +136 -0
- package/dist/public/index.html +14 -0
- package/dist/server.js +1174 -0
- package/package.json +96 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __export = (target, all) => {
|
|
3
|
+
for (var name in all)
|
|
4
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
// src/server/index.ts
|
|
8
|
+
import Fastify from "fastify";
|
|
9
|
+
import cors from "@fastify/cors";
|
|
10
|
+
import fastifyStatic from "@fastify/static";
|
|
11
|
+
import { join as join6, dirname as dirname2 } from "path";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
import { existsSync as existsSync6 } from "fs";
|
|
14
|
+
|
|
15
|
+
// src/server/db/migrate.ts
|
|
16
|
+
import Database from "better-sqlite3";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { homedir } from "os";
|
|
19
|
+
import { mkdirSync } from "fs";
|
|
20
|
+
var DEFAULT_DB_DIR = join(homedir(), ".claude", "state", "claude-master-toolkit");
|
|
21
|
+
var DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, "ctk.sqlite");
|
|
22
|
+
function resolveDbPath() {
|
|
23
|
+
return process.env["CTK_DB_PATH"] ?? DEFAULT_DB_PATH;
|
|
24
|
+
}
|
|
25
|
+
function migrate() {
|
|
26
|
+
const dbPath = resolveDbPath();
|
|
27
|
+
mkdirSync(join(dbPath, ".."), { recursive: true });
|
|
28
|
+
const db = new Database(dbPath);
|
|
29
|
+
db.pragma("journal_mode = WAL");
|
|
30
|
+
db.pragma("foreign_keys = ON");
|
|
31
|
+
db.exec(`
|
|
32
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
33
|
+
id TEXT PRIMARY KEY,
|
|
34
|
+
project_path TEXT NOT NULL,
|
|
35
|
+
started_at INTEGER NOT NULL,
|
|
36
|
+
last_active_at INTEGER NOT NULL,
|
|
37
|
+
primary_model TEXT NOT NULL DEFAULT 'unknown',
|
|
38
|
+
git_branch TEXT,
|
|
39
|
+
version TEXT,
|
|
40
|
+
turn_count INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
total_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
total_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
total_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
total_cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
total_cost_usd REAL NOT NULL DEFAULT 0,
|
|
46
|
+
jsonl_file TEXT NOT NULL
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS token_events (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
52
|
+
timestamp INTEGER NOT NULL,
|
|
53
|
+
model TEXT NOT NULL,
|
|
54
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
56
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
57
|
+
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
58
|
+
cost_usd REAL NOT NULL DEFAULT 0
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
62
|
+
id TEXT PRIMARY KEY,
|
|
63
|
+
title TEXT NOT NULL,
|
|
64
|
+
type TEXT NOT NULL,
|
|
65
|
+
scope TEXT NOT NULL DEFAULT 'project',
|
|
66
|
+
topic_key TEXT,
|
|
67
|
+
content TEXT NOT NULL,
|
|
68
|
+
project_path TEXT,
|
|
69
|
+
session_id TEXT,
|
|
70
|
+
created_at INTEGER NOT NULL,
|
|
71
|
+
updated_at INTEGER NOT NULL
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
75
|
+
file_path TEXT PRIMARY KEY,
|
|
76
|
+
last_byte_offset INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
last_modified INTEGER NOT NULL DEFAULT 0
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_token_events_session ON token_events(session_id);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_token_events_timestamp ON token_events(timestamp);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_memories_topic ON memories(topic_key);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_path);
|
|
87
|
+
`);
|
|
88
|
+
const addColumnSafe = (table, col, type) => {
|
|
89
|
+
try {
|
|
90
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`);
|
|
91
|
+
} catch {
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
addColumnSafe("token_events", "tools_used", "TEXT");
|
|
95
|
+
addColumnSafe("token_events", "stop_reason", "TEXT");
|
|
96
|
+
addColumnSafe("token_events", "is_sidechain", "INTEGER DEFAULT 0");
|
|
97
|
+
addColumnSafe("token_events", "parent_uuid", "TEXT");
|
|
98
|
+
addColumnSafe("token_events", "semantic_phase", "TEXT");
|
|
99
|
+
addColumnSafe("memories", "description", "TEXT");
|
|
100
|
+
addColumnSafe("memories", "file_path", "TEXT");
|
|
101
|
+
addColumnSafe("memories", "access_count", "INTEGER NOT NULL DEFAULT 0");
|
|
102
|
+
addColumnSafe("memories", "last_accessed_at", "INTEGER");
|
|
103
|
+
db.exec(`
|
|
104
|
+
CREATE TABLE IF NOT EXISTS turn_content (
|
|
105
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
106
|
+
event_id INTEGER NOT NULL REFERENCES token_events(id) ON DELETE CASCADE,
|
|
107
|
+
role TEXT NOT NULL,
|
|
108
|
+
content TEXT NOT NULL,
|
|
109
|
+
content_hash TEXT NOT NULL,
|
|
110
|
+
byte_size INTEGER NOT NULL
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
CREATE INDEX IF NOT EXISTS idx_turn_content_event ON turn_content(event_id);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_turn_content_hash ON turn_content(content_hash);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_token_events_phase ON token_events(semantic_phase);
|
|
116
|
+
CREATE INDEX IF NOT EXISTS idx_token_events_sidechain ON token_events(is_sidechain);
|
|
117
|
+
`);
|
|
118
|
+
db.close();
|
|
119
|
+
}
|
|
120
|
+
if (process.argv[1]?.endsWith("migrate.ts") || process.argv[1]?.endsWith("migrate.js")) {
|
|
121
|
+
migrate();
|
|
122
|
+
console.log("Migration complete.");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/server/parser/sync.ts
|
|
126
|
+
import { statSync as statSync2, existsSync as existsSync2 } from "fs";
|
|
127
|
+
import { basename as basename2, dirname } from "path";
|
|
128
|
+
import { eq } from "drizzle-orm";
|
|
129
|
+
|
|
130
|
+
// src/server/db/db.ts
|
|
131
|
+
import Database2 from "better-sqlite3";
|
|
132
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
133
|
+
import { join as join2 } from "path";
|
|
134
|
+
import { homedir as homedir2 } from "os";
|
|
135
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
136
|
+
|
|
137
|
+
// src/server/db/schema.ts
|
|
138
|
+
var schema_exports = {};
|
|
139
|
+
__export(schema_exports, {
|
|
140
|
+
memories: () => memories,
|
|
141
|
+
sessions: () => sessions,
|
|
142
|
+
syncState: () => syncState,
|
|
143
|
+
tokenEvents: () => tokenEvents,
|
|
144
|
+
turnContent: () => turnContent
|
|
145
|
+
});
|
|
146
|
+
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
|
|
147
|
+
var sessions = sqliteTable("sessions", {
|
|
148
|
+
id: text("id").primaryKey(),
|
|
149
|
+
projectPath: text("project_path").notNull(),
|
|
150
|
+
startedAt: integer("started_at").notNull(),
|
|
151
|
+
lastActiveAt: integer("last_active_at").notNull(),
|
|
152
|
+
primaryModel: text("primary_model").notNull().default("unknown"),
|
|
153
|
+
gitBranch: text("git_branch"),
|
|
154
|
+
version: text("version"),
|
|
155
|
+
turnCount: integer("turn_count").notNull().default(0),
|
|
156
|
+
totalInputTokens: integer("total_input_tokens").notNull().default(0),
|
|
157
|
+
totalOutputTokens: integer("total_output_tokens").notNull().default(0),
|
|
158
|
+
totalCacheReadTokens: integer("total_cache_read_tokens").notNull().default(0),
|
|
159
|
+
totalCacheCreationTokens: integer("total_cache_creation_tokens").notNull().default(0),
|
|
160
|
+
totalCostUsd: real("total_cost_usd").notNull().default(0),
|
|
161
|
+
jsonlFile: text("jsonl_file").notNull()
|
|
162
|
+
});
|
|
163
|
+
var tokenEvents = sqliteTable("token_events", {
|
|
164
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
165
|
+
sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
|
|
166
|
+
timestamp: integer("timestamp").notNull(),
|
|
167
|
+
model: text("model").notNull(),
|
|
168
|
+
inputTokens: integer("input_tokens").notNull().default(0),
|
|
169
|
+
outputTokens: integer("output_tokens").notNull().default(0),
|
|
170
|
+
cacheReadTokens: integer("cache_read_tokens").notNull().default(0),
|
|
171
|
+
cacheCreationTokens: integer("cache_creation_tokens").notNull().default(0),
|
|
172
|
+
costUsd: real("cost_usd").notNull().default(0),
|
|
173
|
+
// Enriched columns
|
|
174
|
+
toolsUsed: text("tools_used"),
|
|
175
|
+
// JSON array: '["Read","Grep"]'
|
|
176
|
+
stopReason: text("stop_reason"),
|
|
177
|
+
// "end_turn" | "tool_use" | "max_tokens"
|
|
178
|
+
isSidechain: integer("is_sidechain", { mode: "boolean" }).default(false),
|
|
179
|
+
parentUuid: text("parent_uuid"),
|
|
180
|
+
semanticPhase: text("semantic_phase")
|
|
181
|
+
// "exploration" | "implementation" | "testing" | "unknown"
|
|
182
|
+
});
|
|
183
|
+
var turnContent = sqliteTable("turn_content", {
|
|
184
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
185
|
+
eventId: integer("event_id").notNull().references(() => tokenEvents.id, { onDelete: "cascade" }),
|
|
186
|
+
role: text("role").notNull(),
|
|
187
|
+
// 'user' | 'assistant'
|
|
188
|
+
content: text("content").notNull(),
|
|
189
|
+
// JSON stringified
|
|
190
|
+
contentHash: text("content_hash").notNull(),
|
|
191
|
+
byteSize: integer("byte_size").notNull()
|
|
192
|
+
});
|
|
193
|
+
var memories = sqliteTable("memories", {
|
|
194
|
+
id: text("id").primaryKey(),
|
|
195
|
+
title: text("title").notNull(),
|
|
196
|
+
type: text("type").notNull(),
|
|
197
|
+
// bugfix | decision | architecture | discovery | pattern | config | preference
|
|
198
|
+
scope: text("scope").notNull().default("project"),
|
|
199
|
+
// project | personal
|
|
200
|
+
topicKey: text("topic_key"),
|
|
201
|
+
description: text("description"),
|
|
202
|
+
content: text("content").notNull(),
|
|
203
|
+
projectPath: text("project_path"),
|
|
204
|
+
filePath: text("file_path"),
|
|
205
|
+
sessionId: text("session_id"),
|
|
206
|
+
accessCount: integer("access_count").notNull().default(0),
|
|
207
|
+
lastAccessedAt: integer("last_accessed_at"),
|
|
208
|
+
createdAt: integer("created_at").notNull(),
|
|
209
|
+
updatedAt: integer("updated_at").notNull()
|
|
210
|
+
});
|
|
211
|
+
var syncState = sqliteTable("sync_state", {
|
|
212
|
+
filePath: text("file_path").primaryKey(),
|
|
213
|
+
lastByteOffset: integer("last_byte_offset").notNull().default(0),
|
|
214
|
+
lastModified: integer("last_modified").notNull().default(0)
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// src/server/db/db.ts
|
|
218
|
+
var DEFAULT_DB_DIR2 = join2(homedir2(), ".claude", "state", "claude-master-toolkit");
|
|
219
|
+
var DEFAULT_DB_PATH2 = join2(DEFAULT_DB_DIR2, "ctk.sqlite");
|
|
220
|
+
function resolveDbPath2() {
|
|
221
|
+
return process.env["CTK_DB_PATH"] ?? DEFAULT_DB_PATH2;
|
|
222
|
+
}
|
|
223
|
+
var _db = null;
|
|
224
|
+
var _sqlite = null;
|
|
225
|
+
function getDb() {
|
|
226
|
+
if (_db) return _db;
|
|
227
|
+
const dbPath = resolveDbPath2();
|
|
228
|
+
mkdirSync2(join2(dbPath, ".."), { recursive: true });
|
|
229
|
+
_sqlite = new Database2(dbPath);
|
|
230
|
+
_sqlite.pragma("journal_mode = WAL");
|
|
231
|
+
_sqlite.pragma("foreign_keys = ON");
|
|
232
|
+
_db = drizzle(_sqlite, { schema: schema_exports });
|
|
233
|
+
return _db;
|
|
234
|
+
}
|
|
235
|
+
function closeDb() {
|
|
236
|
+
if (_sqlite) {
|
|
237
|
+
_sqlite.close();
|
|
238
|
+
_sqlite = null;
|
|
239
|
+
_db = null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
process.on("SIGINT", () => {
|
|
243
|
+
closeDb();
|
|
244
|
+
process.exit(0);
|
|
245
|
+
});
|
|
246
|
+
process.on("SIGTERM", () => {
|
|
247
|
+
closeDb();
|
|
248
|
+
process.exit(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// src/shared/jsonl-parser.ts
|
|
252
|
+
import { readFile } from "fs/promises";
|
|
253
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
254
|
+
import { join as join3, basename } from "path";
|
|
255
|
+
import { homedir as homedir3 } from "os";
|
|
256
|
+
import { createHash } from "crypto";
|
|
257
|
+
var CLAUDE_PROJECTS_DIR = join3(homedir3(), ".claude", "projects");
|
|
258
|
+
function listProjectDirs() {
|
|
259
|
+
if (!existsSync(CLAUDE_PROJECTS_DIR)) return [];
|
|
260
|
+
return readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => join3(CLAUDE_PROJECTS_DIR, d.name));
|
|
261
|
+
}
|
|
262
|
+
function listSessionFiles(projectDir) {
|
|
263
|
+
if (!existsSync(projectDir)) return [];
|
|
264
|
+
return readdirSync(projectDir).filter((f) => f.endsWith(".jsonl")).map((f) => join3(projectDir, f)).sort((a, b) => {
|
|
265
|
+
const sa = statSync(a).mtimeMs;
|
|
266
|
+
const sb = statSync(b).mtimeMs;
|
|
267
|
+
return sb - sa;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async function parseJsonlFile(filePath) {
|
|
271
|
+
const content = await readFile(filePath, "utf-8");
|
|
272
|
+
const events = [];
|
|
273
|
+
for (const line of content.split("\n")) {
|
|
274
|
+
if (!line.trim()) continue;
|
|
275
|
+
try {
|
|
276
|
+
events.push(JSON.parse(line));
|
|
277
|
+
} catch {
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return events;
|
|
281
|
+
}
|
|
282
|
+
function extractTokenEvents(events) {
|
|
283
|
+
return events.filter(
|
|
284
|
+
(e) => e.type === "assistant" && !!e.message?.usage
|
|
285
|
+
).map((e) => ({
|
|
286
|
+
timestamp: e.timestamp,
|
|
287
|
+
model: e.message.model ?? "unknown",
|
|
288
|
+
usage: {
|
|
289
|
+
inputTokens: e.message.usage.input_tokens ?? 0,
|
|
290
|
+
outputTokens: e.message.usage.output_tokens ?? 0,
|
|
291
|
+
cacheReadTokens: e.message.usage.cache_read_input_tokens ?? 0,
|
|
292
|
+
cacheCreationTokens: e.message.usage.cache_creation_input_tokens ?? 0
|
|
293
|
+
}
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
function extractToolNames(content) {
|
|
297
|
+
if (!Array.isArray(content)) return [];
|
|
298
|
+
const seen = /* @__PURE__ */ new Set();
|
|
299
|
+
const result = [];
|
|
300
|
+
for (const block of content) {
|
|
301
|
+
if (typeof block === "object" && block !== null && block.type === "tool_use" && typeof block.name === "string") {
|
|
302
|
+
const name = block.name;
|
|
303
|
+
if (!seen.has(name)) {
|
|
304
|
+
seen.add(name);
|
|
305
|
+
result.push(name);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
var EXPLORATION_TOOLS = /* @__PURE__ */ new Set(["Read", "Grep", "Glob", "Agent", "Explore", "WebSearch", "WebFetch"]);
|
|
312
|
+
var IMPLEMENTATION_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "NotebookEdit"]);
|
|
313
|
+
var TEST_PATTERNS = [/\bvitest\b/, /\bjest\b/, /\bpytest\b/, /\btest\b/, /\bspec\b/];
|
|
314
|
+
function extractBashCommands(content) {
|
|
315
|
+
if (!Array.isArray(content)) return [];
|
|
316
|
+
const cmds = [];
|
|
317
|
+
for (const block of content) {
|
|
318
|
+
if (typeof block === "object" && block !== null && block.type === "tool_use" && block.name === "Bash") {
|
|
319
|
+
const input = block.input;
|
|
320
|
+
if (input && typeof input.command === "string") {
|
|
321
|
+
cmds.push(input.command);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return cmds;
|
|
326
|
+
}
|
|
327
|
+
function inferSemanticPhase(tools, bashCommands = []) {
|
|
328
|
+
if (tools.length === 0) return "unknown";
|
|
329
|
+
if (tools.includes("Bash") && bashCommands.some((cmd) => TEST_PATTERNS.some((p) => p.test(cmd)))) {
|
|
330
|
+
return "testing";
|
|
331
|
+
}
|
|
332
|
+
if (tools.some((t) => IMPLEMENTATION_TOOLS.has(t))) return "implementation";
|
|
333
|
+
if (tools.includes("Bash")) return "implementation";
|
|
334
|
+
if (tools.every((t) => EXPLORATION_TOOLS.has(t))) return "exploration";
|
|
335
|
+
return "unknown";
|
|
336
|
+
}
|
|
337
|
+
function extractEnrichedTokenEvents(events) {
|
|
338
|
+
return events.filter(
|
|
339
|
+
(e) => e.type === "assistant" && !!e.message?.usage
|
|
340
|
+
).map((e) => {
|
|
341
|
+
const content = e.message.content;
|
|
342
|
+
const toolsUsed = extractToolNames(content);
|
|
343
|
+
const bashCommands = extractBashCommands(content);
|
|
344
|
+
const contentStr = JSON.stringify(content);
|
|
345
|
+
const contentHash = createHash("sha256").update(contentStr).digest("hex");
|
|
346
|
+
return {
|
|
347
|
+
timestamp: e.timestamp,
|
|
348
|
+
model: e.message.model ?? "unknown",
|
|
349
|
+
usage: {
|
|
350
|
+
inputTokens: e.message.usage.input_tokens ?? 0,
|
|
351
|
+
outputTokens: e.message.usage.output_tokens ?? 0,
|
|
352
|
+
cacheReadTokens: e.message.usage.cache_read_input_tokens ?? 0,
|
|
353
|
+
cacheCreationTokens: e.message.usage.cache_creation_input_tokens ?? 0
|
|
354
|
+
},
|
|
355
|
+
toolsUsed,
|
|
356
|
+
stopReason: e.message.stop_reason ?? "unknown",
|
|
357
|
+
isSidechain: e.isSidechain ?? false,
|
|
358
|
+
parentUuid: e.parentUuid,
|
|
359
|
+
semanticPhase: inferSemanticPhase(toolsUsed, bashCommands),
|
|
360
|
+
content: contentStr,
|
|
361
|
+
contentHash
|
|
362
|
+
};
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
function extractSessionMeta(events) {
|
|
366
|
+
const first = events[0];
|
|
367
|
+
const last = events.at(-1);
|
|
368
|
+
const modelCounts = /* @__PURE__ */ new Map();
|
|
369
|
+
for (const e of events) {
|
|
370
|
+
if (e.type === "assistant" && e.message?.model) {
|
|
371
|
+
const m = e.message.model;
|
|
372
|
+
modelCounts.set(m, (modelCounts.get(m) ?? 0) + 1);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const primaryModel = [...modelCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown";
|
|
376
|
+
const turnCount = events.filter((e) => e.type === "assistant").length;
|
|
377
|
+
return {
|
|
378
|
+
sessionId: first?.sessionId ?? basename(first?.uuid ?? "unknown"),
|
|
379
|
+
startedAt: first?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
380
|
+
lastActiveAt: last?.timestamp ?? first?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
381
|
+
cwd: first?.cwd ?? "",
|
|
382
|
+
gitBranch: first?.gitBranch,
|
|
383
|
+
version: first?.version,
|
|
384
|
+
primaryModel,
|
|
385
|
+
turnCount
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/shared/pricing.ts
|
|
390
|
+
var PRICING_TABLE = {
|
|
391
|
+
opus: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
392
|
+
sonnet: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
393
|
+
haiku: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }
|
|
394
|
+
};
|
|
395
|
+
var MODEL_ALIASES = {
|
|
396
|
+
"claude-opus-4-6": "opus",
|
|
397
|
+
"claude-sonnet-4-6": "sonnet",
|
|
398
|
+
"claude-sonnet-4-5-20241022": "sonnet",
|
|
399
|
+
"claude-haiku-4-5-20251001": "haiku"
|
|
400
|
+
};
|
|
401
|
+
function resolveModelKey(model) {
|
|
402
|
+
if (PRICING_TABLE[model]) return model;
|
|
403
|
+
if (MODEL_ALIASES[model]) return MODEL_ALIASES[model];
|
|
404
|
+
if (model.includes("opus")) return "opus";
|
|
405
|
+
if (model.includes("sonnet")) return "sonnet";
|
|
406
|
+
if (model.includes("haiku")) return "haiku";
|
|
407
|
+
return "sonnet";
|
|
408
|
+
}
|
|
409
|
+
function getPricing(model) {
|
|
410
|
+
const key = resolveModelKey(model);
|
|
411
|
+
return PRICING_TABLE[key] ?? PRICING_TABLE["sonnet"];
|
|
412
|
+
}
|
|
413
|
+
function computeCost(model, tokens) {
|
|
414
|
+
const p = getPricing(model);
|
|
415
|
+
return (tokens.inputTokens * p.input + tokens.outputTokens * p.output + tokens.cacheReadTokens * p.cacheRead + tokens.cacheCreationTokens * p.cacheWrite) / 1e6;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/server/parser/sync.ts
|
|
419
|
+
async function syncFile(filePath) {
|
|
420
|
+
if (!existsSync2(filePath)) return;
|
|
421
|
+
const db = getDb();
|
|
422
|
+
const stat = statSync2(filePath);
|
|
423
|
+
const existing = db.select().from(syncState).where(eq(syncState.filePath, filePath)).get();
|
|
424
|
+
if (existing?.lastModified === stat.mtimeMs) return;
|
|
425
|
+
const allEvents = await parseJsonlFile(filePath);
|
|
426
|
+
const meta = extractSessionMeta(allEvents);
|
|
427
|
+
const tokenEvts = extractTokenEvents(allEvents);
|
|
428
|
+
const totalTokens = tokenEvts.reduce(
|
|
429
|
+
(acc, e) => ({
|
|
430
|
+
inputTokens: acc.inputTokens + e.usage.inputTokens,
|
|
431
|
+
outputTokens: acc.outputTokens + e.usage.outputTokens,
|
|
432
|
+
cacheReadTokens: acc.cacheReadTokens + e.usage.cacheReadTokens,
|
|
433
|
+
cacheCreationTokens: acc.cacheCreationTokens + e.usage.cacheCreationTokens
|
|
434
|
+
}),
|
|
435
|
+
{ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }
|
|
436
|
+
);
|
|
437
|
+
const totalCost = tokenEvts.reduce(
|
|
438
|
+
(acc, e) => acc + computeCost(e.model, e.usage),
|
|
439
|
+
0
|
|
440
|
+
);
|
|
441
|
+
const projectDir = basename2(dirname(filePath));
|
|
442
|
+
const projectPath = projectDir.replace(/^-/, "/").replace(/-/g, "/");
|
|
443
|
+
db.insert(sessions).values({
|
|
444
|
+
id: meta.sessionId,
|
|
445
|
+
projectPath,
|
|
446
|
+
startedAt: new Date(meta.startedAt).getTime(),
|
|
447
|
+
lastActiveAt: new Date(meta.lastActiveAt).getTime(),
|
|
448
|
+
primaryModel: meta.primaryModel,
|
|
449
|
+
gitBranch: meta.gitBranch,
|
|
450
|
+
version: meta.version,
|
|
451
|
+
turnCount: meta.turnCount,
|
|
452
|
+
totalInputTokens: totalTokens.inputTokens,
|
|
453
|
+
totalOutputTokens: totalTokens.outputTokens,
|
|
454
|
+
totalCacheReadTokens: totalTokens.cacheReadTokens,
|
|
455
|
+
totalCacheCreationTokens: totalTokens.cacheCreationTokens,
|
|
456
|
+
totalCostUsd: totalCost,
|
|
457
|
+
jsonlFile: filePath
|
|
458
|
+
}).onConflictDoUpdate({
|
|
459
|
+
target: sessions.id,
|
|
460
|
+
set: {
|
|
461
|
+
lastActiveAt: new Date(meta.lastActiveAt).getTime(),
|
|
462
|
+
primaryModel: meta.primaryModel,
|
|
463
|
+
turnCount: meta.turnCount,
|
|
464
|
+
totalInputTokens: totalTokens.inputTokens,
|
|
465
|
+
totalOutputTokens: totalTokens.outputTokens,
|
|
466
|
+
totalCacheReadTokens: totalTokens.cacheReadTokens,
|
|
467
|
+
totalCacheCreationTokens: totalTokens.cacheCreationTokens,
|
|
468
|
+
totalCostUsd: totalCost
|
|
469
|
+
}
|
|
470
|
+
}).run();
|
|
471
|
+
db.delete(tokenEvents).where(eq(tokenEvents.sessionId, meta.sessionId)).run();
|
|
472
|
+
const enrichedEvts = extractEnrichedTokenEvents(allEvents);
|
|
473
|
+
for (const evt of enrichedEvts) {
|
|
474
|
+
const cost = computeCost(evt.model, evt.usage);
|
|
475
|
+
const inserted = db.insert(tokenEvents).values({
|
|
476
|
+
sessionId: meta.sessionId,
|
|
477
|
+
timestamp: new Date(evt.timestamp).getTime(),
|
|
478
|
+
model: evt.model,
|
|
479
|
+
inputTokens: evt.usage.inputTokens,
|
|
480
|
+
outputTokens: evt.usage.outputTokens,
|
|
481
|
+
cacheReadTokens: evt.usage.cacheReadTokens,
|
|
482
|
+
cacheCreationTokens: evt.usage.cacheCreationTokens,
|
|
483
|
+
costUsd: cost,
|
|
484
|
+
toolsUsed: JSON.stringify(evt.toolsUsed),
|
|
485
|
+
stopReason: evt.stopReason,
|
|
486
|
+
isSidechain: evt.isSidechain,
|
|
487
|
+
parentUuid: evt.parentUuid,
|
|
488
|
+
semanticPhase: evt.semanticPhase
|
|
489
|
+
}).returning({ id: tokenEvents.id }).get();
|
|
490
|
+
if (inserted && evt.content) {
|
|
491
|
+
db.insert(turnContent).values({
|
|
492
|
+
eventId: inserted.id,
|
|
493
|
+
role: "assistant",
|
|
494
|
+
content: evt.content,
|
|
495
|
+
contentHash: evt.contentHash,
|
|
496
|
+
byteSize: Buffer.byteLength(evt.content, "utf-8")
|
|
497
|
+
}).run();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
db.insert(syncState).values({ filePath, lastByteOffset: 0, lastModified: stat.mtimeMs }).onConflictDoUpdate({
|
|
501
|
+
target: syncState.filePath,
|
|
502
|
+
set: { lastByteOffset: 0, lastModified: stat.mtimeMs }
|
|
503
|
+
}).run();
|
|
504
|
+
}
|
|
505
|
+
async function syncAll() {
|
|
506
|
+
const projectDirs = listProjectDirs();
|
|
507
|
+
let fileCount = 0;
|
|
508
|
+
for (const dir of projectDirs) {
|
|
509
|
+
const files = listSessionFiles(dir);
|
|
510
|
+
for (const file of files) {
|
|
511
|
+
await syncFile(file);
|
|
512
|
+
fileCount++;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
const db = getDb();
|
|
516
|
+
const sessionCount = db.select().from(sessions).all().length;
|
|
517
|
+
return { files: fileCount, sessions: sessionCount };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// src/server/parser/watcher.ts
|
|
521
|
+
import { watch } from "chokidar";
|
|
522
|
+
import { join as join4 } from "path";
|
|
523
|
+
import { homedir as homedir4 } from "os";
|
|
524
|
+
var CLAUDE_PROJECTS_DIR2 = join4(homedir4(), ".claude", "projects");
|
|
525
|
+
function startWatcher(callback) {
|
|
526
|
+
const watcher = watch(join4(CLAUDE_PROJECTS_DIR2, "**", "*.jsonl"), {
|
|
527
|
+
persistent: true,
|
|
528
|
+
ignoreInitial: true,
|
|
529
|
+
awaitWriteFinish: {
|
|
530
|
+
stabilityThreshold: 500,
|
|
531
|
+
pollInterval: 100
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
watcher.on("change", async (filePath) => {
|
|
535
|
+
try {
|
|
536
|
+
await syncFile(filePath);
|
|
537
|
+
callback?.("synced", filePath);
|
|
538
|
+
} catch (err) {
|
|
539
|
+
console.error(`[watcher] Error syncing ${filePath}:`, err);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
watcher.on("add", async (filePath) => {
|
|
543
|
+
try {
|
|
544
|
+
await syncFile(filePath);
|
|
545
|
+
callback?.("synced", filePath);
|
|
546
|
+
} catch (err) {
|
|
547
|
+
console.error(`[watcher] Error syncing new file ${filePath}:`, err);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
return watcher;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/server/parser/engram-import.ts
|
|
554
|
+
import { readdirSync as readdirSync2, readFileSync, existsSync as existsSync3 } from "fs";
|
|
555
|
+
import { join as join5 } from "path";
|
|
556
|
+
import { randomUUID } from "crypto";
|
|
557
|
+
import { eq as eq2, and } from "drizzle-orm";
|
|
558
|
+
function parseMemoryFile(filePath) {
|
|
559
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
560
|
+
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
561
|
+
if (!fmMatch) {
|
|
562
|
+
return { content: raw };
|
|
563
|
+
}
|
|
564
|
+
const frontmatter = fmMatch[1];
|
|
565
|
+
const content = fmMatch[2].trim();
|
|
566
|
+
const fields = {};
|
|
567
|
+
for (const line of frontmatter.split("\n")) {
|
|
568
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
569
|
+
if (match) {
|
|
570
|
+
fields[match[1]] = match[2].trim();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
name: fields["name"],
|
|
575
|
+
description: fields["description"],
|
|
576
|
+
type: fields["type"],
|
|
577
|
+
topicKey: fields["topicKey"],
|
|
578
|
+
originSessionId: fields["originSessionId"],
|
|
579
|
+
content
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function mapMemoryType(type) {
|
|
583
|
+
const mapping = {
|
|
584
|
+
user: "preference",
|
|
585
|
+
feedback: "preference",
|
|
586
|
+
project: "decision",
|
|
587
|
+
reference: "discovery",
|
|
588
|
+
bugfix: "bugfix",
|
|
589
|
+
decision: "decision",
|
|
590
|
+
architecture: "architecture",
|
|
591
|
+
discovery: "discovery",
|
|
592
|
+
pattern: "pattern",
|
|
593
|
+
config: "config",
|
|
594
|
+
preference: "preference"
|
|
595
|
+
};
|
|
596
|
+
return mapping[type ?? ""] ?? "discovery";
|
|
597
|
+
}
|
|
598
|
+
async function importAllMemories() {
|
|
599
|
+
const db = getDb();
|
|
600
|
+
const projectDirs = listProjectDirs();
|
|
601
|
+
let imported = 0;
|
|
602
|
+
let skipped = 0;
|
|
603
|
+
for (const dir of projectDirs) {
|
|
604
|
+
const memoryDir = join5(dir, "memory");
|
|
605
|
+
if (!existsSync3(memoryDir)) continue;
|
|
606
|
+
const files = readdirSync2(memoryDir).filter(
|
|
607
|
+
(f) => f.endsWith(".md") && f !== "MEMORY.md"
|
|
608
|
+
);
|
|
609
|
+
const dirName = dir.split("/").pop() ?? "";
|
|
610
|
+
const projectPath = dirName.replace(/^-/, "/").replace(/-/g, "/");
|
|
611
|
+
for (const file of files) {
|
|
612
|
+
const filePath = join5(memoryDir, file);
|
|
613
|
+
const parsed = parseMemoryFile(filePath);
|
|
614
|
+
if (!parsed) {
|
|
615
|
+
skipped++;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const title = parsed.name ?? file.replace(".md", "");
|
|
619
|
+
const now = Date.now();
|
|
620
|
+
try {
|
|
621
|
+
const existing = db.select({ id: memories.id }).from(memories).where(and(eq2(memories.title, title), eq2(memories.projectPath, projectPath))).get();
|
|
622
|
+
if (existing) {
|
|
623
|
+
db.update(memories).set({
|
|
624
|
+
type: mapMemoryType(parsed.type),
|
|
625
|
+
description: parsed.description,
|
|
626
|
+
content: parsed.content,
|
|
627
|
+
filePath,
|
|
628
|
+
topicKey: parsed.topicKey,
|
|
629
|
+
sessionId: parsed.originSessionId,
|
|
630
|
+
updatedAt: now
|
|
631
|
+
}).where(eq2(memories.id, existing.id)).run();
|
|
632
|
+
} else {
|
|
633
|
+
const id = randomUUID();
|
|
634
|
+
db.insert(memories).values({
|
|
635
|
+
id,
|
|
636
|
+
title,
|
|
637
|
+
type: mapMemoryType(parsed.type),
|
|
638
|
+
scope: "project",
|
|
639
|
+
description: parsed.description,
|
|
640
|
+
content: parsed.content,
|
|
641
|
+
filePath,
|
|
642
|
+
topicKey: parsed.topicKey,
|
|
643
|
+
projectPath,
|
|
644
|
+
sessionId: parsed.originSessionId,
|
|
645
|
+
createdAt: now,
|
|
646
|
+
updatedAt: now
|
|
647
|
+
}).run();
|
|
648
|
+
}
|
|
649
|
+
imported++;
|
|
650
|
+
} catch (e) {
|
|
651
|
+
console.error(`[engram-import] Error processing ${filePath}:`, e);
|
|
652
|
+
skipped++;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return { imported, skipped };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// src/server/routes/sessions.ts
|
|
660
|
+
import { desc, eq as eq3 } from "drizzle-orm";
|
|
661
|
+
import { execSync } from "child_process";
|
|
662
|
+
import { existsSync as existsSync4 } from "fs";
|
|
663
|
+
async function sessionsRoutes(app) {
|
|
664
|
+
app.get("/sessions", async (_req, reply) => {
|
|
665
|
+
const db = getDb();
|
|
666
|
+
const rows = db.select().from(sessions).orderBy(desc(sessions.lastActiveAt)).limit(100).all();
|
|
667
|
+
const data = rows.map((r) => ({
|
|
668
|
+
id: r.id,
|
|
669
|
+
projectPath: r.projectPath,
|
|
670
|
+
startedAt: r.startedAt,
|
|
671
|
+
lastActiveAt: r.lastActiveAt,
|
|
672
|
+
primaryModel: r.primaryModel,
|
|
673
|
+
gitBranch: r.gitBranch,
|
|
674
|
+
turnCount: r.turnCount,
|
|
675
|
+
tokens: {
|
|
676
|
+
inputTokens: r.totalInputTokens,
|
|
677
|
+
outputTokens: r.totalOutputTokens,
|
|
678
|
+
cacheReadTokens: r.totalCacheReadTokens,
|
|
679
|
+
cacheCreationTokens: r.totalCacheCreationTokens
|
|
680
|
+
},
|
|
681
|
+
costUsd: r.totalCostUsd
|
|
682
|
+
}));
|
|
683
|
+
return reply.send(data);
|
|
684
|
+
});
|
|
685
|
+
app.get("/sessions/:id", async (req, reply) => {
|
|
686
|
+
const db = getDb();
|
|
687
|
+
const session = db.select().from(sessions).where(eq3(sessions.id, req.params.id)).get();
|
|
688
|
+
if (!session) {
|
|
689
|
+
return reply.status(404).send({ error: "Session not found" });
|
|
690
|
+
}
|
|
691
|
+
const events = db.select().from(tokenEvents).where(eq3(tokenEvents.sessionId, req.params.id)).orderBy(tokenEvents.timestamp).all();
|
|
692
|
+
const modelBreakdown = {};
|
|
693
|
+
for (const e of events) {
|
|
694
|
+
const key = e.model;
|
|
695
|
+
if (!modelBreakdown[key]) {
|
|
696
|
+
modelBreakdown[key] = {
|
|
697
|
+
inputTokens: 0,
|
|
698
|
+
outputTokens: 0,
|
|
699
|
+
cacheReadTokens: 0,
|
|
700
|
+
cacheCreationTokens: 0,
|
|
701
|
+
costUsd: 0,
|
|
702
|
+
turns: 0
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
modelBreakdown[key].inputTokens += e.inputTokens;
|
|
706
|
+
modelBreakdown[key].outputTokens += e.outputTokens;
|
|
707
|
+
modelBreakdown[key].cacheReadTokens += e.cacheReadTokens;
|
|
708
|
+
modelBreakdown[key].cacheCreationTokens += e.cacheCreationTokens;
|
|
709
|
+
modelBreakdown[key].costUsd += e.costUsd;
|
|
710
|
+
modelBreakdown[key].turns++;
|
|
711
|
+
}
|
|
712
|
+
return reply.send({
|
|
713
|
+
id: session.id,
|
|
714
|
+
projectPath: session.projectPath,
|
|
715
|
+
startedAt: session.startedAt,
|
|
716
|
+
lastActiveAt: session.lastActiveAt,
|
|
717
|
+
primaryModel: session.primaryModel,
|
|
718
|
+
gitBranch: session.gitBranch,
|
|
719
|
+
version: session.version,
|
|
720
|
+
turnCount: session.turnCount,
|
|
721
|
+
tokens: {
|
|
722
|
+
inputTokens: session.totalInputTokens,
|
|
723
|
+
outputTokens: session.totalOutputTokens,
|
|
724
|
+
cacheReadTokens: session.totalCacheReadTokens,
|
|
725
|
+
cacheCreationTokens: session.totalCacheCreationTokens
|
|
726
|
+
},
|
|
727
|
+
costUsd: session.totalCostUsd,
|
|
728
|
+
events,
|
|
729
|
+
modelBreakdown
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
app.get("/sessions/:id/git-stats", async (req, reply) => {
|
|
733
|
+
const db = getDb();
|
|
734
|
+
const session = db.select().from(sessions).where(eq3(sessions.id, req.params.id)).get();
|
|
735
|
+
if (!session) {
|
|
736
|
+
return reply.status(404).send({ error: "Session not found" });
|
|
737
|
+
}
|
|
738
|
+
const projectPath = session.projectPath;
|
|
739
|
+
if (!existsSync4(projectPath)) {
|
|
740
|
+
return reply.send({ insertions: 0, deletions: 0, filesChanged: 0, available: false });
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
const startISO = new Date(session.startedAt).toISOString();
|
|
744
|
+
const endISO = new Date(session.lastActiveAt + 5 * 60 * 1e3).toISOString();
|
|
745
|
+
const output = execSync(
|
|
746
|
+
`git -C "${projectPath}" log --numstat --format="" --after="${startISO}" --before="${endISO}" 2>/dev/null || echo ""`,
|
|
747
|
+
{ encoding: "utf-8", stdio: "pipe" }
|
|
748
|
+
);
|
|
749
|
+
let insertions = 0;
|
|
750
|
+
let deletions = 0;
|
|
751
|
+
const filesChanged = /* @__PURE__ */ new Set();
|
|
752
|
+
for (const line of output.split("\n")) {
|
|
753
|
+
const parts = line.trim().split(" ");
|
|
754
|
+
if (parts.length >= 3) {
|
|
755
|
+
const ins = parseInt(parts[0], 10);
|
|
756
|
+
const dels = parseInt(parts[1], 10);
|
|
757
|
+
if (!isNaN(ins) && !isNaN(dels)) {
|
|
758
|
+
insertions += ins;
|
|
759
|
+
deletions += dels;
|
|
760
|
+
filesChanged.add(parts[2] || "");
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return reply.send({
|
|
765
|
+
insertions,
|
|
766
|
+
deletions,
|
|
767
|
+
filesChanged: filesChanged.size,
|
|
768
|
+
available: true
|
|
769
|
+
});
|
|
770
|
+
} catch (e) {
|
|
771
|
+
console.error(`[sessions] Error getting git stats for ${projectPath}:`, e);
|
|
772
|
+
return reply.send({ insertions: 0, deletions: 0, filesChanged: 0, available: false });
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// src/server/routes/stats.ts
|
|
778
|
+
import { desc as desc2, sql as sql2, gte } from "drizzle-orm";
|
|
779
|
+
async function statsRoutes(app) {
|
|
780
|
+
app.get("/stats/current", async (_req, reply) => {
|
|
781
|
+
const db = getDb();
|
|
782
|
+
const latestSession = db.select().from(sessions).orderBy(desc2(sessions.lastActiveAt)).limit(1).get();
|
|
783
|
+
const totalSessions = db.select({ count: sql2`count(*)` }).from(sessions).get();
|
|
784
|
+
const totalCost = db.select({ total: sql2`sum(total_cost_usd)` }).from(sessions).get();
|
|
785
|
+
const totalTurns = db.select({ count: sql2`sum(turn_count)` }).from(sessions).get();
|
|
786
|
+
const sessionCount = totalSessions?.count ?? 0;
|
|
787
|
+
const totalCostUsd = totalCost?.total ?? 0;
|
|
788
|
+
const totalTurnCount = totalTurns?.count ?? 0;
|
|
789
|
+
return reply.send({
|
|
790
|
+
latestSession: latestSession ? {
|
|
791
|
+
id: latestSession.id,
|
|
792
|
+
projectPath: latestSession.projectPath,
|
|
793
|
+
primaryModel: latestSession.primaryModel,
|
|
794
|
+
costUsd: latestSession.totalCostUsd,
|
|
795
|
+
turnCount: latestSession.turnCount,
|
|
796
|
+
lastActiveAt: latestSession.lastActiveAt
|
|
797
|
+
} : null,
|
|
798
|
+
totalSessions: sessionCount,
|
|
799
|
+
totalCostUsd,
|
|
800
|
+
totalTurns: totalTurnCount,
|
|
801
|
+
avgCostPerSession: sessionCount > 0 ? totalCostUsd / sessionCount : 0
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
app.get("/stats/timeline", async (_req, reply) => {
|
|
805
|
+
const db = getDb();
|
|
806
|
+
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1e3;
|
|
807
|
+
const rows = db.select({
|
|
808
|
+
date: sql2`date(timestamp / 1000, 'unixepoch')`,
|
|
809
|
+
costUsd: sql2`sum(cost_usd)`,
|
|
810
|
+
inputTokens: sql2`sum(input_tokens)`,
|
|
811
|
+
outputTokens: sql2`sum(output_tokens)`,
|
|
812
|
+
sessions: sql2`count(distinct session_id)`
|
|
813
|
+
}).from(tokenEvents).where(gte(tokenEvents.timestamp, sevenDaysAgo)).groupBy(sql2`date(timestamp / 1000, 'unixepoch')`).orderBy(sql2`date(timestamp / 1000, 'unixepoch')`).all();
|
|
814
|
+
return reply.send(rows);
|
|
815
|
+
});
|
|
816
|
+
app.get("/stats/models", async (_req, reply) => {
|
|
817
|
+
const db = getDb();
|
|
818
|
+
const rows = db.select({
|
|
819
|
+
model: tokenEvents.model,
|
|
820
|
+
totalInput: sql2`sum(input_tokens)`,
|
|
821
|
+
totalOutput: sql2`sum(output_tokens)`,
|
|
822
|
+
totalCacheRead: sql2`sum(cache_read_tokens)`,
|
|
823
|
+
totalCacheCreation: sql2`sum(cache_creation_tokens)`,
|
|
824
|
+
costUsd: sql2`sum(cost_usd)`,
|
|
825
|
+
turns: sql2`count(*)`,
|
|
826
|
+
sessionCount: sql2`count(distinct session_id)`
|
|
827
|
+
}).from(tokenEvents).groupBy(tokenEvents.model).all();
|
|
828
|
+
const totalCost = rows.reduce((acc, r) => acc + (r.costUsd ?? 0), 0);
|
|
829
|
+
const data = rows.map((r) => ({
|
|
830
|
+
model: r.model,
|
|
831
|
+
modelKey: resolveModelKey(r.model),
|
|
832
|
+
totalTokens: (r.totalInput ?? 0) + (r.totalOutput ?? 0) + (r.totalCacheRead ?? 0) + (r.totalCacheCreation ?? 0),
|
|
833
|
+
tokens: {
|
|
834
|
+
input: r.totalInput ?? 0,
|
|
835
|
+
output: r.totalOutput ?? 0,
|
|
836
|
+
cacheRead: r.totalCacheRead ?? 0,
|
|
837
|
+
cacheCreation: r.totalCacheCreation ?? 0
|
|
838
|
+
},
|
|
839
|
+
costUsd: r.costUsd ?? 0,
|
|
840
|
+
turns: r.turns ?? 0,
|
|
841
|
+
sessionCount: r.sessionCount ?? 0,
|
|
842
|
+
percentage: totalCost > 0 ? (r.costUsd ?? 0) / totalCost * 100 : 0
|
|
843
|
+
}));
|
|
844
|
+
return reply.send(data);
|
|
845
|
+
});
|
|
846
|
+
app.get("/stats/efficiency", async (req, reply) => {
|
|
847
|
+
const db = getDb();
|
|
848
|
+
const filter = req.query.sessionId ? sql2`AND session_id = ${req.query.sessionId}` : sql2``;
|
|
849
|
+
const rows = db.all(sql2`
|
|
850
|
+
WITH tool_rows AS (
|
|
851
|
+
SELECT
|
|
852
|
+
json_each.value AS tool,
|
|
853
|
+
input_tokens,
|
|
854
|
+
output_tokens,
|
|
855
|
+
cost_usd
|
|
856
|
+
FROM token_events, json_each(tools_used)
|
|
857
|
+
WHERE tools_used IS NOT NULL ${filter}
|
|
858
|
+
)
|
|
859
|
+
SELECT
|
|
860
|
+
tool,
|
|
861
|
+
COUNT(*) AS count,
|
|
862
|
+
ROUND(AVG(input_tokens), 0) AS avgInput,
|
|
863
|
+
ROUND(AVG(output_tokens), 0) AS avgOutput,
|
|
864
|
+
ROUND(AVG(cost_usd), 6) AS avgCost
|
|
865
|
+
FROM tool_rows
|
|
866
|
+
GROUP BY tool
|
|
867
|
+
ORDER BY count DESC
|
|
868
|
+
`);
|
|
869
|
+
const overall = db.get(sql2`
|
|
870
|
+
SELECT
|
|
871
|
+
ROUND(AVG(input_tokens + output_tokens), 0) AS avgTokens,
|
|
872
|
+
ROUND(AVG(cost_usd), 6) AS avgCost
|
|
873
|
+
FROM token_events
|
|
874
|
+
WHERE tools_used IS NOT NULL ${filter}
|
|
875
|
+
`);
|
|
876
|
+
return reply.send({
|
|
877
|
+
perTool: Object.fromEntries(
|
|
878
|
+
rows.map((r) => [r.tool, { count: r.count, avgInputTokens: r.avgInput, avgOutputTokens: r.avgOutput, avgCostUsd: r.avgCost }])
|
|
879
|
+
),
|
|
880
|
+
overall: {
|
|
881
|
+
avgTokensPerTurn: overall?.avgTokens ?? 0,
|
|
882
|
+
avgCostPerTurn: overall?.avgCost ?? 0
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
app.get("/stats/phases", async (req, reply) => {
|
|
887
|
+
const db = getDb();
|
|
888
|
+
const filter = req.query.sessionId ? sql2`AND session_id = ${req.query.sessionId}` : sql2``;
|
|
889
|
+
const rows = db.all(sql2`
|
|
890
|
+
SELECT
|
|
891
|
+
semantic_phase AS phase,
|
|
892
|
+
COUNT(*) AS turns,
|
|
893
|
+
SUM(input_tokens + output_tokens) AS tokens
|
|
894
|
+
FROM token_events
|
|
895
|
+
WHERE semantic_phase IS NOT NULL ${filter}
|
|
896
|
+
GROUP BY semantic_phase
|
|
897
|
+
`);
|
|
898
|
+
const totalTurns = rows.reduce((acc, r) => acc + r.turns, 0);
|
|
899
|
+
const data = Object.fromEntries(
|
|
900
|
+
rows.map((r) => [
|
|
901
|
+
r.phase,
|
|
902
|
+
{
|
|
903
|
+
turns: r.turns,
|
|
904
|
+
pct: totalTurns > 0 ? Math.round(r.turns / totalTurns * 100) : 0,
|
|
905
|
+
tokens: r.tokens
|
|
906
|
+
}
|
|
907
|
+
])
|
|
908
|
+
);
|
|
909
|
+
return reply.send(data);
|
|
910
|
+
});
|
|
911
|
+
app.get("/stats/tools", async (req, reply) => {
|
|
912
|
+
const db = getDb();
|
|
913
|
+
const filter = req.query.sessionId ? sql2`AND session_id = ${req.query.sessionId}` : sql2``;
|
|
914
|
+
const freqRows = db.all(sql2`
|
|
915
|
+
SELECT json_each.value AS tool, COUNT(*) AS count
|
|
916
|
+
FROM token_events, json_each(tools_used)
|
|
917
|
+
WHERE tools_used IS NOT NULL ${filter}
|
|
918
|
+
GROUP BY tool
|
|
919
|
+
ORDER BY count DESC
|
|
920
|
+
`);
|
|
921
|
+
const comboRows = db.all(sql2`
|
|
922
|
+
SELECT tools_used AS combo, COUNT(*) AS count
|
|
923
|
+
FROM token_events
|
|
924
|
+
WHERE tools_used IS NOT NULL
|
|
925
|
+
AND json_array_length(tools_used) >= 2
|
|
926
|
+
${filter}
|
|
927
|
+
GROUP BY tools_used
|
|
928
|
+
ORDER BY count DESC
|
|
929
|
+
LIMIT 10
|
|
930
|
+
`);
|
|
931
|
+
return reply.send({
|
|
932
|
+
frequency: Object.fromEntries(freqRows.map((r) => [r.tool, r.count])),
|
|
933
|
+
combos: comboRows.map((r) => ({
|
|
934
|
+
tools: JSON.parse(r.combo),
|
|
935
|
+
count: r.count
|
|
936
|
+
}))
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
app.get("/stats/score", async (req, reply) => {
|
|
940
|
+
const db = getDb();
|
|
941
|
+
const sessionFilter = req.query.sessionId ? sql2`WHERE session_id = ${req.query.sessionId}` : sql2``;
|
|
942
|
+
const metrics = db.get(sql2`
|
|
943
|
+
SELECT
|
|
944
|
+
ROUND(AVG(input_tokens + output_tokens), 0) AS avgTokensPerTurn,
|
|
945
|
+
COUNT(*) AS totalTurns,
|
|
946
|
+
SUM(cache_read_tokens) AS totalCacheRead,
|
|
947
|
+
SUM(input_tokens) AS totalInput,
|
|
948
|
+
SUM(CASE WHEN semantic_phase = 'exploration' THEN 1 ELSE 0 END) AS explorationTurns,
|
|
949
|
+
SUM(CASE WHEN semantic_phase = 'implementation' THEN 1 ELSE 0 END) AS implementationTurns,
|
|
950
|
+
SUM(CASE WHEN semantic_phase = 'testing' THEN 1 ELSE 0 END) AS testingTurns
|
|
951
|
+
FROM token_events
|
|
952
|
+
${sessionFilter}
|
|
953
|
+
`);
|
|
954
|
+
if (!metrics || metrics.totalTurns === 0) {
|
|
955
|
+
return reply.send({ score: 0, breakdown: { tokensPerTurn: 0, cacheHitRatio: 0, errorRecovery: 0, phaseBalance: 0 } });
|
|
956
|
+
}
|
|
957
|
+
const tptScore = Math.max(0, Math.min(100, Math.round(100 - (metrics.avgTokensPerTurn - 2e3) / 180)));
|
|
958
|
+
const cacheRatio = metrics.totalInput > 0 ? metrics.totalCacheRead / metrics.totalInput : 0;
|
|
959
|
+
const cacheScore = Math.round(Math.min(100, cacheRatio * 100));
|
|
960
|
+
const total = metrics.totalTurns;
|
|
961
|
+
const expPct = metrics.explorationTurns / total;
|
|
962
|
+
const impPct = metrics.implementationTurns / total;
|
|
963
|
+
const testPct = metrics.testingTurns / total;
|
|
964
|
+
const distance = Math.abs(expPct - 0.2) + Math.abs(impPct - 0.6) + Math.abs(testPct - 0.2);
|
|
965
|
+
const phaseScore = Math.round(Math.max(0, 100 - distance * 100));
|
|
966
|
+
const errorRecovery = 75;
|
|
967
|
+
const score = Math.round((tptScore + cacheScore + phaseScore + errorRecovery) / 4);
|
|
968
|
+
return reply.send({
|
|
969
|
+
sessionId: req.query.sessionId ?? "all",
|
|
970
|
+
score,
|
|
971
|
+
breakdown: {
|
|
972
|
+
tokensPerTurn: tptScore,
|
|
973
|
+
cacheHitRatio: cacheScore,
|
|
974
|
+
errorRecovery,
|
|
975
|
+
phaseBalance: phaseScore
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// src/server/routes/memories.ts
|
|
982
|
+
import { and as and2, desc as desc3, eq as eq5, like, or } from "drizzle-orm";
|
|
983
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
984
|
+
import { writeFileSync, existsSync as existsSync5 } from "fs";
|
|
985
|
+
async function memoriesRoutes(app) {
|
|
986
|
+
app.get(
|
|
987
|
+
"/memories",
|
|
988
|
+
async (req, reply) => {
|
|
989
|
+
const db = getDb();
|
|
990
|
+
const conditions = [];
|
|
991
|
+
if (req.query.type) {
|
|
992
|
+
conditions.push(eq5(memories.type, req.query.type));
|
|
993
|
+
}
|
|
994
|
+
if (req.query.project) {
|
|
995
|
+
conditions.push(eq5(memories.projectPath, req.query.project));
|
|
996
|
+
}
|
|
997
|
+
if (req.query.search) {
|
|
998
|
+
const s = `%${req.query.search}%`;
|
|
999
|
+
conditions.push(or(like(memories.title, s), like(memories.content, s), like(memories.topicKey, s)));
|
|
1000
|
+
}
|
|
1001
|
+
const rows = db.select().from(memories).where(conditions.length > 0 ? and2(...conditions) : void 0).orderBy(desc3(memories.updatedAt)).all();
|
|
1002
|
+
return reply.send(rows);
|
|
1003
|
+
}
|
|
1004
|
+
);
|
|
1005
|
+
app.get("/memories/:id", async (req, reply) => {
|
|
1006
|
+
const db = getDb();
|
|
1007
|
+
const memory = db.select().from(memories).where(eq5(memories.id, req.params.id)).get();
|
|
1008
|
+
if (!memory) {
|
|
1009
|
+
return reply.status(404).send({ error: "Memory not found" });
|
|
1010
|
+
}
|
|
1011
|
+
db.update(memories).set({
|
|
1012
|
+
accessCount: (memory.accessCount || 0) + 1,
|
|
1013
|
+
lastAccessedAt: Date.now()
|
|
1014
|
+
}).where(eq5(memories.id, req.params.id)).run();
|
|
1015
|
+
return reply.send(memory);
|
|
1016
|
+
});
|
|
1017
|
+
app.post("/memories", async (req, reply) => {
|
|
1018
|
+
const db = getDb();
|
|
1019
|
+
const id = randomUUID2();
|
|
1020
|
+
const now = Date.now();
|
|
1021
|
+
db.insert(memories).values({
|
|
1022
|
+
id,
|
|
1023
|
+
title: req.body.title,
|
|
1024
|
+
type: req.body.type,
|
|
1025
|
+
scope: req.body.scope ?? "project",
|
|
1026
|
+
topicKey: req.body.topicKey,
|
|
1027
|
+
description: req.body.description,
|
|
1028
|
+
content: req.body.content,
|
|
1029
|
+
projectPath: req.body.projectPath,
|
|
1030
|
+
filePath: req.body.filePath,
|
|
1031
|
+
sessionId: req.body.sessionId,
|
|
1032
|
+
createdAt: now,
|
|
1033
|
+
updatedAt: now
|
|
1034
|
+
}).run();
|
|
1035
|
+
return reply.status(201).send({ id, created: true });
|
|
1036
|
+
});
|
|
1037
|
+
app.patch("/memories/:id", async (req, reply) => {
|
|
1038
|
+
const db = getDb();
|
|
1039
|
+
const existing = db.select().from(memories).where(eq5(memories.id, req.params.id)).get();
|
|
1040
|
+
if (!existing) {
|
|
1041
|
+
return reply.status(404).send({ error: "Memory not found" });
|
|
1042
|
+
}
|
|
1043
|
+
db.update(memories).set({
|
|
1044
|
+
...req.body,
|
|
1045
|
+
updatedAt: Date.now()
|
|
1046
|
+
}).where(eq5(memories.id, req.params.id)).run();
|
|
1047
|
+
return reply.send({ updated: true });
|
|
1048
|
+
});
|
|
1049
|
+
app.post("/memories/:id/sync", async (req, reply) => {
|
|
1050
|
+
const db = getDb();
|
|
1051
|
+
const memory = db.select().from(memories).where(eq5(memories.id, req.params.id)).get();
|
|
1052
|
+
if (!memory) {
|
|
1053
|
+
return reply.status(404).send({ error: "Memory not found" });
|
|
1054
|
+
}
|
|
1055
|
+
if (!memory.filePath) {
|
|
1056
|
+
return reply.status(400).send({ error: "No file path associated with this memory" });
|
|
1057
|
+
}
|
|
1058
|
+
if (!existsSync5(memory.filePath)) {
|
|
1059
|
+
return reply.status(400).send({ error: "File does not exist on disk" });
|
|
1060
|
+
}
|
|
1061
|
+
const frontmatter = [
|
|
1062
|
+
"---",
|
|
1063
|
+
`name: ${memory.title}`,
|
|
1064
|
+
`description: ${memory.description || ""}`,
|
|
1065
|
+
`type: ${memory.type}`,
|
|
1066
|
+
memory.topicKey ? `topicKey: ${memory.topicKey}` : null,
|
|
1067
|
+
memory.sessionId ? `originSessionId: ${memory.sessionId}` : null,
|
|
1068
|
+
"---"
|
|
1069
|
+
].filter(Boolean).join("\n");
|
|
1070
|
+
const content = `${frontmatter}
|
|
1071
|
+
|
|
1072
|
+
${memory.content}`;
|
|
1073
|
+
try {
|
|
1074
|
+
writeFileSync(memory.filePath, content, "utf-8");
|
|
1075
|
+
return reply.send({ synced: true });
|
|
1076
|
+
} catch (e) {
|
|
1077
|
+
console.error(`[memories] Error syncing to ${memory.filePath}:`, e);
|
|
1078
|
+
return reply.status(500).send({ error: "Failed to write file" });
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
app.delete("/memories/:id", async (req, reply) => {
|
|
1082
|
+
const db = getDb();
|
|
1083
|
+
db.delete(memories).where(eq5(memories.id, req.params.id)).run();
|
|
1084
|
+
return reply.send({ deleted: true });
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// src/server/routes/health.ts
|
|
1089
|
+
import { sql as sql3 } from "drizzle-orm";
|
|
1090
|
+
async function healthRoutes(app) {
|
|
1091
|
+
app.get("/health", async (_req, reply) => {
|
|
1092
|
+
const db = getDb();
|
|
1093
|
+
const sessionCount = db.select({ count: sql3`count(*)` }).from(sessions).get();
|
|
1094
|
+
const eventCount = db.select({ count: sql3`count(*)` }).from(tokenEvents).get();
|
|
1095
|
+
const memoryCount = db.select({ count: sql3`count(*)` }).from(memories).get();
|
|
1096
|
+
return reply.send({
|
|
1097
|
+
status: "ok",
|
|
1098
|
+
timestamp: Date.now(),
|
|
1099
|
+
counts: {
|
|
1100
|
+
sessions: sessionCount?.count ?? 0,
|
|
1101
|
+
tokenEvents: eventCount?.count ?? 0,
|
|
1102
|
+
memories: memoryCount?.count ?? 0
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// src/server/index.ts
|
|
1109
|
+
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
1110
|
+
async function startServer(port = 3200) {
|
|
1111
|
+
migrate();
|
|
1112
|
+
const app = Fastify({ logger: false });
|
|
1113
|
+
await app.register(cors, { origin: true });
|
|
1114
|
+
const publicDir = join6(__dirname, "public");
|
|
1115
|
+
if (existsSync6(publicDir)) {
|
|
1116
|
+
await app.register(fastifyStatic, {
|
|
1117
|
+
root: publicDir,
|
|
1118
|
+
prefix: "/",
|
|
1119
|
+
wildcard: false
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
await app.register(sessionsRoutes, { prefix: "/api" });
|
|
1123
|
+
await app.register(statsRoutes, { prefix: "/api" });
|
|
1124
|
+
await app.register(memoriesRoutes, { prefix: "/api" });
|
|
1125
|
+
await app.register(healthRoutes, { prefix: "/api" });
|
|
1126
|
+
app.setNotFoundHandler((_req, reply) => {
|
|
1127
|
+
const indexPath = join6(publicDir, "index.html");
|
|
1128
|
+
if (existsSync6(indexPath)) {
|
|
1129
|
+
return reply.sendFile("index.html");
|
|
1130
|
+
}
|
|
1131
|
+
return reply.status(404).send({ error: "Not found" });
|
|
1132
|
+
});
|
|
1133
|
+
startWatcher((event, filePath) => {
|
|
1134
|
+
if (event === "synced") {
|
|
1135
|
+
console.log(`[watcher] Synced: ${filePath}`);
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
let attempts = 0;
|
|
1139
|
+
const maxAttempts = 5;
|
|
1140
|
+
while (attempts < maxAttempts) {
|
|
1141
|
+
try {
|
|
1142
|
+
await app.listen({ port, host: "0.0.0.0" });
|
|
1143
|
+
break;
|
|
1144
|
+
} catch (err) {
|
|
1145
|
+
if (err?.code === "EADDRINUSE" && attempts < maxAttempts - 1) {
|
|
1146
|
+
attempts++;
|
|
1147
|
+
const delay = Math.min(1e3 * Math.pow(2, attempts), 5e3);
|
|
1148
|
+
console.log(`[ctk] Port ${port} in use, retrying in ${delay}ms...`);
|
|
1149
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1150
|
+
} else {
|
|
1151
|
+
throw err;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
syncAll().then(
|
|
1156
|
+
(r) => console.log(`[ctk] Synced ${r.files} files, ${r.sessions} sessions`)
|
|
1157
|
+
).catch(console.error);
|
|
1158
|
+
importAllMemories().then((r) => {
|
|
1159
|
+
if (r.imported > 0)
|
|
1160
|
+
console.log(
|
|
1161
|
+
`[ctk] Imported ${r.imported} memories (${r.skipped} skipped)`
|
|
1162
|
+
);
|
|
1163
|
+
}).catch(console.error);
|
|
1164
|
+
}
|
|
1165
|
+
var isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
1166
|
+
if (isDirectRun) {
|
|
1167
|
+
startServer().catch((err) => {
|
|
1168
|
+
console.error("[ctk] Failed to start server:", err);
|
|
1169
|
+
process.exit(1);
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
export {
|
|
1173
|
+
startServer
|
|
1174
|
+
};
|