claude-master-toolkit 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -20
- package/bin/ctk.js +14 -0
- package/dist/cli.js +1116 -17
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
See what your sessions actually cost. Cut context bloat before it bites. Query your history in a real dashboard instead of squinting at `/cost`.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install -g
|
|
9
|
-
|
|
8
|
+
npm install -g claude-master-toolkit
|
|
9
|
+
ctk dashboard
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
Opens a local dashboard at `http://localhost:3200` backed by SQLite. Reads your Claude Code session JSONL files directly — no API keys, no telemetry, no accounts.
|
|
@@ -48,19 +48,19 @@ The server watches `~/.claude/projects/` and syncs new session events in real ti
|
|
|
48
48
|
|
|
49
49
|
All commands are designed to return **compact, Claude-friendly output** — ideal for the `Bash` tool in agent loops.
|
|
50
50
|
|
|
51
|
-
| Command
|
|
52
|
-
|
|
53
|
-
| `workctl cost [--quiet]`
|
|
54
|
-
| `workctl context`
|
|
55
|
-
| `workctl tokens [file]`
|
|
56
|
-
| `workctl estimate <file>`
|
|
57
|
-
| `workctl slice <file> <symbol>` | Extract just one function / class / type from a file
|
|
58
|
-
| `workctl find <query> [path]`
|
|
59
|
-
| `workctl git-log [N]`
|
|
60
|
-
| `workctl git-changed`
|
|
61
|
-
| `workctl test-summary [cmd]`
|
|
62
|
-
| `workctl model <phase>`
|
|
63
|
-
| `workctl dashboard`
|
|
51
|
+
| Command | What it does |
|
|
52
|
+
| ------------------------------- | --------------------------------------------------------------------------- |
|
|
53
|
+
| `workctl cost [--quiet]` | Realized cost of the current session (reads session JSONL, applies pricing) |
|
|
54
|
+
| `workctl context` | Current window usage, cumulative totals, cost, threshold advice |
|
|
55
|
+
| `workctl tokens [file]` | Rough token count for a file or stdin (`chars / 4`) |
|
|
56
|
+
| `workctl estimate <file>` | Faithful pre-flight count via Anthropic `count_tokens` API |
|
|
57
|
+
| `workctl slice <file> <symbol>` | Extract just one function / class / type from a file |
|
|
58
|
+
| `workctl find <query> [path]` | Ranked ripgrep, top 20 results |
|
|
59
|
+
| `workctl git-log [N]` | One-line log for last N commits |
|
|
60
|
+
| `workctl git-changed` | Files changed vs main branch with line counts |
|
|
61
|
+
| `workctl test-summary [cmd]` | Run a test command, print only pass/fail summary |
|
|
62
|
+
| `workctl model <phase>` | Model alias recommendation per SDD phase |
|
|
63
|
+
| `workctl dashboard` | Launch the local metrics dashboard |
|
|
64
64
|
|
|
65
65
|
Every command supports `--json` for machine-readable output.
|
|
66
66
|
|
|
@@ -70,11 +70,11 @@ Every command supports `--json` for machine-readable output.
|
|
|
70
70
|
|
|
71
71
|
Three fidelity sources, picked per use case:
|
|
72
72
|
|
|
73
|
-
| Source
|
|
74
|
-
|
|
75
|
-
| Session JSONL (`~/.claude/projects/*/*.jsonl`) | **100%** — real API-charged counts | `cost`, `context`, dashboard
|
|
76
|
-
| Anthropic `count_tokens` API
|
|
77
|
-
| `chars / 4`
|
|
73
|
+
| Source | Fidelity | Used by |
|
|
74
|
+
| ---------------------------------------------- | ---------------------------------- | -------------------------------------- |
|
|
75
|
+
| Session JSONL (`~/.claude/projects/*/*.jsonl`) | **100%** — real API-charged counts | `cost`, `context`, dashboard |
|
|
76
|
+
| Anthropic `count_tokens` API | **100%** — official endpoint | `estimate` (needs `ANTHROPIC_API_KEY`) |
|
|
77
|
+
| `chars / 4` | rough | `tokens`, fallback for `estimate` |
|
|
78
78
|
|
|
79
79
|
Pricing table is hard-coded in `src/shared/pricing.ts` for the Claude 4.6 / 4.5 family. Update there when Anthropic changes prices.
|
|
80
80
|
|
package/bin/ctk.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
|
|
12
|
+
const cliPath = path.join(__dirname, "../dist/cli.js");
|
|
13
|
+
|
|
14
|
+
await import(cliPath);
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
2
3
|
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
4
|
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
5
|
}) : x)(function(x) {
|
|
5
6
|
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
7
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
8
|
});
|
|
9
|
+
var __export = (target, all) => {
|
|
10
|
+
for (var name in all)
|
|
11
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
12
|
+
};
|
|
8
13
|
|
|
9
14
|
// src/cli/index.ts
|
|
10
15
|
import { Command } from "commander";
|
|
@@ -44,9 +49,9 @@ function boxedOutput(title, lines) {
|
|
|
44
49
|
// --text-muted
|
|
45
50
|
});
|
|
46
51
|
}
|
|
47
|
-
function spinner(
|
|
52
|
+
function spinner(text2) {
|
|
48
53
|
return ora({
|
|
49
|
-
text: c.muted(
|
|
54
|
+
text: c.muted(text2),
|
|
50
55
|
spinner: "dots",
|
|
51
56
|
stream: process.stdout
|
|
52
57
|
});
|
|
@@ -70,8 +75,8 @@ function formatTokensColored(n) {
|
|
|
70
75
|
function padRight(str, width) {
|
|
71
76
|
return str + " ".repeat(Math.max(0, width - str.length));
|
|
72
77
|
}
|
|
73
|
-
function arrow(
|
|
74
|
-
return `${c.accent("\u2192")} ${
|
|
78
|
+
function arrow(text2) {
|
|
79
|
+
return `${c.accent("\u2192")} ${text2}`;
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
// src/shared/output.ts
|
|
@@ -320,7 +325,7 @@ function tokensCommand(file) {
|
|
|
320
325
|
}
|
|
321
326
|
}
|
|
322
327
|
async function estimateCommand(file) {
|
|
323
|
-
let
|
|
328
|
+
let text2;
|
|
324
329
|
if (file === "-") {
|
|
325
330
|
const chunks = [];
|
|
326
331
|
const { createInterface } = await import("readline");
|
|
@@ -328,9 +333,9 @@ async function estimateCommand(file) {
|
|
|
328
333
|
for await (const line of rl) {
|
|
329
334
|
chunks.push(line);
|
|
330
335
|
}
|
|
331
|
-
|
|
336
|
+
text2 = chunks.join("\n");
|
|
332
337
|
} else if (existsSync4(file)) {
|
|
333
|
-
|
|
338
|
+
text2 = readFileSync4(file, "utf-8");
|
|
334
339
|
} else {
|
|
335
340
|
outputError(`estimate: not a file: ${file} (use '-' for stdin)`);
|
|
336
341
|
return;
|
|
@@ -347,7 +352,7 @@ async function estimateCommand(file) {
|
|
|
347
352
|
},
|
|
348
353
|
body: JSON.stringify({
|
|
349
354
|
model: "claude-sonnet-4-5",
|
|
350
|
-
messages: [{ role: "user", content:
|
|
355
|
+
messages: [{ role: "user", content: text2 }]
|
|
351
356
|
})
|
|
352
357
|
});
|
|
353
358
|
const data = await response.json();
|
|
@@ -375,9 +380,9 @@ async function estimateCommand(file) {
|
|
|
375
380
|
}
|
|
376
381
|
}
|
|
377
382
|
}
|
|
378
|
-
const rough = Math.floor(
|
|
383
|
+
const rough = Math.floor(text2.length / 4);
|
|
379
384
|
if (isJsonMode()) {
|
|
380
|
-
output({ tokens: rough, method: "rough", chars:
|
|
385
|
+
output({ tokens: rough, method: "rough", chars: text2.length });
|
|
381
386
|
} else {
|
|
382
387
|
console.log(`${rough} tokens (rough \u2014 set ANTHROPIC_API_KEY for exact count)`);
|
|
383
388
|
}
|
|
@@ -391,10 +396,15 @@ import { readFile } from "fs/promises";
|
|
|
391
396
|
import { existsSync as existsSync5, readdirSync, statSync } from "fs";
|
|
392
397
|
import { join as join3, basename } from "path";
|
|
393
398
|
import { homedir as homedir3 } from "os";
|
|
399
|
+
import { createHash } from "crypto";
|
|
394
400
|
var CLAUDE_PROJECTS_DIR = join3(homedir3(), ".claude", "projects");
|
|
395
401
|
function encodeCwd(cwd) {
|
|
396
402
|
return cwd.replace(/\//g, "-");
|
|
397
403
|
}
|
|
404
|
+
function listProjectDirs() {
|
|
405
|
+
if (!existsSync5(CLAUDE_PROJECTS_DIR)) return [];
|
|
406
|
+
return readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => join3(CLAUDE_PROJECTS_DIR, d.name));
|
|
407
|
+
}
|
|
398
408
|
function listSessionFiles(projectDir) {
|
|
399
409
|
if (!existsSync5(projectDir)) return [];
|
|
400
410
|
return readdirSync(projectDir).filter((f) => f.endsWith(".jsonl")).map((f) => join3(projectDir, f)).sort((a, b) => {
|
|
@@ -436,10 +446,79 @@ function extractTokenEvents(events) {
|
|
|
436
446
|
}
|
|
437
447
|
}));
|
|
438
448
|
}
|
|
449
|
+
function extractToolNames(content) {
|
|
450
|
+
if (!Array.isArray(content)) return [];
|
|
451
|
+
const seen = /* @__PURE__ */ new Set();
|
|
452
|
+
const result = [];
|
|
453
|
+
for (const block of content) {
|
|
454
|
+
if (typeof block === "object" && block !== null && block.type === "tool_use" && typeof block.name === "string") {
|
|
455
|
+
const name = block.name;
|
|
456
|
+
if (!seen.has(name)) {
|
|
457
|
+
seen.add(name);
|
|
458
|
+
result.push(name);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
var EXPLORATION_TOOLS = /* @__PURE__ */ new Set(["Read", "Grep", "Glob", "Agent", "Explore", "WebSearch", "WebFetch"]);
|
|
465
|
+
var IMPLEMENTATION_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "NotebookEdit"]);
|
|
466
|
+
var TEST_PATTERNS = [/\bvitest\b/, /\bjest\b/, /\bpytest\b/, /\btest\b/, /\bspec\b/];
|
|
467
|
+
function extractBashCommands(content) {
|
|
468
|
+
if (!Array.isArray(content)) return [];
|
|
469
|
+
const cmds = [];
|
|
470
|
+
for (const block of content) {
|
|
471
|
+
if (typeof block === "object" && block !== null && block.type === "tool_use" && block.name === "Bash") {
|
|
472
|
+
const input = block.input;
|
|
473
|
+
if (input && typeof input.command === "string") {
|
|
474
|
+
cmds.push(input.command);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return cmds;
|
|
479
|
+
}
|
|
480
|
+
function inferSemanticPhase(tools, bashCommands = []) {
|
|
481
|
+
if (tools.length === 0) return "unknown";
|
|
482
|
+
if (tools.includes("Bash") && bashCommands.some((cmd) => TEST_PATTERNS.some((p) => p.test(cmd)))) {
|
|
483
|
+
return "testing";
|
|
484
|
+
}
|
|
485
|
+
if (tools.some((t) => IMPLEMENTATION_TOOLS.has(t))) return "implementation";
|
|
486
|
+
if (tools.includes("Bash")) return "implementation";
|
|
487
|
+
if (tools.every((t) => EXPLORATION_TOOLS.has(t))) return "exploration";
|
|
488
|
+
return "unknown";
|
|
489
|
+
}
|
|
490
|
+
function extractEnrichedTokenEvents(events) {
|
|
491
|
+
return events.filter(
|
|
492
|
+
(e) => e.type === "assistant" && !!e.message?.usage
|
|
493
|
+
).map((e) => {
|
|
494
|
+
const content = e.message.content;
|
|
495
|
+
const toolsUsed = extractToolNames(content);
|
|
496
|
+
const bashCommands = extractBashCommands(content);
|
|
497
|
+
const contentStr = JSON.stringify(content);
|
|
498
|
+
const contentHash = createHash("sha256").update(contentStr).digest("hex");
|
|
499
|
+
return {
|
|
500
|
+
timestamp: e.timestamp,
|
|
501
|
+
model: e.message.model ?? "unknown",
|
|
502
|
+
usage: {
|
|
503
|
+
inputTokens: e.message.usage.input_tokens ?? 0,
|
|
504
|
+
outputTokens: e.message.usage.output_tokens ?? 0,
|
|
505
|
+
cacheReadTokens: e.message.usage.cache_read_input_tokens ?? 0,
|
|
506
|
+
cacheCreationTokens: e.message.usage.cache_creation_input_tokens ?? 0
|
|
507
|
+
},
|
|
508
|
+
toolsUsed,
|
|
509
|
+
stopReason: e.message.stop_reason ?? "unknown",
|
|
510
|
+
isSidechain: e.isSidechain ?? false,
|
|
511
|
+
parentUuid: e.parentUuid,
|
|
512
|
+
semanticPhase: inferSemanticPhase(toolsUsed, bashCommands),
|
|
513
|
+
content: contentStr,
|
|
514
|
+
contentHash
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
}
|
|
439
518
|
async function getSessionTokens(filePath) {
|
|
440
519
|
const events = await parseJsonlFile(filePath);
|
|
441
|
-
const
|
|
442
|
-
return
|
|
520
|
+
const tokenEvents2 = extractTokenEvents(events);
|
|
521
|
+
return tokenEvents2.reduce(
|
|
443
522
|
(acc, e) => ({
|
|
444
523
|
inputTokens: acc.inputTokens + e.usage.inputTokens,
|
|
445
524
|
outputTokens: acc.outputTokens + e.usage.outputTokens,
|
|
@@ -451,13 +530,36 @@ async function getSessionTokens(filePath) {
|
|
|
451
530
|
}
|
|
452
531
|
async function getLatestTurnUsage(filePath) {
|
|
453
532
|
const events = await parseJsonlFile(filePath);
|
|
454
|
-
const
|
|
455
|
-
const last =
|
|
533
|
+
const tokenEvents2 = extractTokenEvents(events);
|
|
534
|
+
const last = tokenEvents2.at(-1);
|
|
456
535
|
if (!last) {
|
|
457
536
|
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
|
|
458
537
|
}
|
|
459
538
|
return last.usage;
|
|
460
539
|
}
|
|
540
|
+
function extractSessionMeta(events) {
|
|
541
|
+
const first = events[0];
|
|
542
|
+
const last = events.at(-1);
|
|
543
|
+
const modelCounts = /* @__PURE__ */ new Map();
|
|
544
|
+
for (const e of events) {
|
|
545
|
+
if (e.type === "assistant" && e.message?.model) {
|
|
546
|
+
const m = e.message.model;
|
|
547
|
+
modelCounts.set(m, (modelCounts.get(m) ?? 0) + 1);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const primaryModel = [...modelCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown";
|
|
551
|
+
const turnCount = events.filter((e) => e.type === "assistant").length;
|
|
552
|
+
return {
|
|
553
|
+
sessionId: first?.sessionId ?? basename(first?.uuid ?? "unknown"),
|
|
554
|
+
startedAt: first?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
555
|
+
lastActiveAt: last?.timestamp ?? first?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
556
|
+
cwd: first?.cwd ?? "",
|
|
557
|
+
gitBranch: first?.gitBranch,
|
|
558
|
+
version: first?.version,
|
|
559
|
+
primaryModel,
|
|
560
|
+
turnCount
|
|
561
|
+
};
|
|
562
|
+
}
|
|
461
563
|
|
|
462
564
|
// src/cli/commands/cost.ts
|
|
463
565
|
async function costCommand(options) {
|
|
@@ -625,13 +727,1010 @@ exit: ${error.status ?? 1}`);
|
|
|
625
727
|
}
|
|
626
728
|
}
|
|
627
729
|
|
|
730
|
+
// src/server/index.ts
|
|
731
|
+
import Fastify from "fastify";
|
|
732
|
+
import cors from "@fastify/cors";
|
|
733
|
+
import fastifyStatic from "@fastify/static";
|
|
734
|
+
import { join as join8, dirname as dirname3 } from "path";
|
|
735
|
+
import { fileURLToPath } from "url";
|
|
736
|
+
import { existsSync as existsSync10 } from "fs";
|
|
737
|
+
|
|
738
|
+
// src/server/db/migrate.ts
|
|
739
|
+
import Database from "better-sqlite3";
|
|
740
|
+
import { join as join4 } from "path";
|
|
741
|
+
import { homedir as homedir4 } from "os";
|
|
742
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
743
|
+
var DEFAULT_DB_DIR = join4(homedir4(), ".claude", "state", "claude-master-toolkit");
|
|
744
|
+
var DEFAULT_DB_PATH = join4(DEFAULT_DB_DIR, "ctk.sqlite");
|
|
745
|
+
function resolveDbPath() {
|
|
746
|
+
return process.env["CTK_DB_PATH"] ?? DEFAULT_DB_PATH;
|
|
747
|
+
}
|
|
748
|
+
function migrate() {
|
|
749
|
+
const dbPath = resolveDbPath();
|
|
750
|
+
mkdirSync2(join4(dbPath, ".."), { recursive: true });
|
|
751
|
+
const db = new Database(dbPath);
|
|
752
|
+
db.pragma("journal_mode = WAL");
|
|
753
|
+
db.pragma("foreign_keys = ON");
|
|
754
|
+
db.exec(`
|
|
755
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
756
|
+
id TEXT PRIMARY KEY,
|
|
757
|
+
project_path TEXT NOT NULL,
|
|
758
|
+
started_at INTEGER NOT NULL,
|
|
759
|
+
last_active_at INTEGER NOT NULL,
|
|
760
|
+
primary_model TEXT NOT NULL DEFAULT 'unknown',
|
|
761
|
+
git_branch TEXT,
|
|
762
|
+
version TEXT,
|
|
763
|
+
turn_count INTEGER NOT NULL DEFAULT 0,
|
|
764
|
+
total_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
765
|
+
total_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
766
|
+
total_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
767
|
+
total_cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
768
|
+
total_cost_usd REAL NOT NULL DEFAULT 0,
|
|
769
|
+
jsonl_file TEXT NOT NULL
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
CREATE TABLE IF NOT EXISTS token_events (
|
|
773
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
774
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
775
|
+
timestamp INTEGER NOT NULL,
|
|
776
|
+
model TEXT NOT NULL,
|
|
777
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
778
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
779
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
780
|
+
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
781
|
+
cost_usd REAL NOT NULL DEFAULT 0
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
785
|
+
id TEXT PRIMARY KEY,
|
|
786
|
+
title TEXT NOT NULL,
|
|
787
|
+
type TEXT NOT NULL,
|
|
788
|
+
scope TEXT NOT NULL DEFAULT 'project',
|
|
789
|
+
topic_key TEXT,
|
|
790
|
+
content TEXT NOT NULL,
|
|
791
|
+
project_path TEXT,
|
|
792
|
+
session_id TEXT,
|
|
793
|
+
created_at INTEGER NOT NULL,
|
|
794
|
+
updated_at INTEGER NOT NULL
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
798
|
+
file_path TEXT PRIMARY KEY,
|
|
799
|
+
last_byte_offset INTEGER NOT NULL DEFAULT 0,
|
|
800
|
+
last_modified INTEGER NOT NULL DEFAULT 0
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
CREATE INDEX IF NOT EXISTS idx_token_events_session ON token_events(session_id);
|
|
804
|
+
CREATE INDEX IF NOT EXISTS idx_token_events_timestamp ON token_events(timestamp);
|
|
805
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path);
|
|
806
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
|
|
807
|
+
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
|
|
808
|
+
CREATE INDEX IF NOT EXISTS idx_memories_topic ON memories(topic_key);
|
|
809
|
+
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_path);
|
|
810
|
+
`);
|
|
811
|
+
const addColumnSafe = (table, col, type) => {
|
|
812
|
+
try {
|
|
813
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`);
|
|
814
|
+
} catch {
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
addColumnSafe("token_events", "tools_used", "TEXT");
|
|
818
|
+
addColumnSafe("token_events", "stop_reason", "TEXT");
|
|
819
|
+
addColumnSafe("token_events", "is_sidechain", "INTEGER DEFAULT 0");
|
|
820
|
+
addColumnSafe("token_events", "parent_uuid", "TEXT");
|
|
821
|
+
addColumnSafe("token_events", "semantic_phase", "TEXT");
|
|
822
|
+
addColumnSafe("memories", "description", "TEXT");
|
|
823
|
+
addColumnSafe("memories", "file_path", "TEXT");
|
|
824
|
+
addColumnSafe("memories", "access_count", "INTEGER NOT NULL DEFAULT 0");
|
|
825
|
+
addColumnSafe("memories", "last_accessed_at", "INTEGER");
|
|
826
|
+
db.exec(`
|
|
827
|
+
CREATE TABLE IF NOT EXISTS turn_content (
|
|
828
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
829
|
+
event_id INTEGER NOT NULL REFERENCES token_events(id) ON DELETE CASCADE,
|
|
830
|
+
role TEXT NOT NULL,
|
|
831
|
+
content TEXT NOT NULL,
|
|
832
|
+
content_hash TEXT NOT NULL,
|
|
833
|
+
byte_size INTEGER NOT NULL
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
CREATE INDEX IF NOT EXISTS idx_turn_content_event ON turn_content(event_id);
|
|
837
|
+
CREATE INDEX IF NOT EXISTS idx_turn_content_hash ON turn_content(content_hash);
|
|
838
|
+
CREATE INDEX IF NOT EXISTS idx_token_events_phase ON token_events(semantic_phase);
|
|
839
|
+
CREATE INDEX IF NOT EXISTS idx_token_events_sidechain ON token_events(is_sidechain);
|
|
840
|
+
`);
|
|
841
|
+
db.close();
|
|
842
|
+
}
|
|
843
|
+
if (process.argv[1]?.endsWith("migrate.ts") || process.argv[1]?.endsWith("migrate.js")) {
|
|
844
|
+
migrate();
|
|
845
|
+
console.log("Migration complete.");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// src/server/parser/sync.ts
|
|
849
|
+
import { statSync as statSync2, existsSync as existsSync6 } from "fs";
|
|
850
|
+
import { basename as basename3, dirname as dirname2 } from "path";
|
|
851
|
+
import { eq } from "drizzle-orm";
|
|
852
|
+
|
|
853
|
+
// src/server/db/db.ts
|
|
854
|
+
import Database2 from "better-sqlite3";
|
|
855
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
856
|
+
import { join as join5 } from "path";
|
|
857
|
+
import { homedir as homedir5 } from "os";
|
|
858
|
+
import { mkdirSync as mkdirSync3 } from "fs";
|
|
859
|
+
|
|
860
|
+
// src/server/db/schema.ts
|
|
861
|
+
var schema_exports = {};
|
|
862
|
+
__export(schema_exports, {
|
|
863
|
+
memories: () => memories,
|
|
864
|
+
sessions: () => sessions,
|
|
865
|
+
syncState: () => syncState,
|
|
866
|
+
tokenEvents: () => tokenEvents,
|
|
867
|
+
turnContent: () => turnContent
|
|
868
|
+
});
|
|
869
|
+
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
|
|
870
|
+
var sessions = sqliteTable("sessions", {
|
|
871
|
+
id: text("id").primaryKey(),
|
|
872
|
+
projectPath: text("project_path").notNull(),
|
|
873
|
+
startedAt: integer("started_at").notNull(),
|
|
874
|
+
lastActiveAt: integer("last_active_at").notNull(),
|
|
875
|
+
primaryModel: text("primary_model").notNull().default("unknown"),
|
|
876
|
+
gitBranch: text("git_branch"),
|
|
877
|
+
version: text("version"),
|
|
878
|
+
turnCount: integer("turn_count").notNull().default(0),
|
|
879
|
+
totalInputTokens: integer("total_input_tokens").notNull().default(0),
|
|
880
|
+
totalOutputTokens: integer("total_output_tokens").notNull().default(0),
|
|
881
|
+
totalCacheReadTokens: integer("total_cache_read_tokens").notNull().default(0),
|
|
882
|
+
totalCacheCreationTokens: integer("total_cache_creation_tokens").notNull().default(0),
|
|
883
|
+
totalCostUsd: real("total_cost_usd").notNull().default(0),
|
|
884
|
+
jsonlFile: text("jsonl_file").notNull()
|
|
885
|
+
});
|
|
886
|
+
var tokenEvents = sqliteTable("token_events", {
|
|
887
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
888
|
+
sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
|
|
889
|
+
timestamp: integer("timestamp").notNull(),
|
|
890
|
+
model: text("model").notNull(),
|
|
891
|
+
inputTokens: integer("input_tokens").notNull().default(0),
|
|
892
|
+
outputTokens: integer("output_tokens").notNull().default(0),
|
|
893
|
+
cacheReadTokens: integer("cache_read_tokens").notNull().default(0),
|
|
894
|
+
cacheCreationTokens: integer("cache_creation_tokens").notNull().default(0),
|
|
895
|
+
costUsd: real("cost_usd").notNull().default(0),
|
|
896
|
+
// Enriched columns
|
|
897
|
+
toolsUsed: text("tools_used"),
|
|
898
|
+
// JSON array: '["Read","Grep"]'
|
|
899
|
+
stopReason: text("stop_reason"),
|
|
900
|
+
// "end_turn" | "tool_use" | "max_tokens"
|
|
901
|
+
isSidechain: integer("is_sidechain", { mode: "boolean" }).default(false),
|
|
902
|
+
parentUuid: text("parent_uuid"),
|
|
903
|
+
semanticPhase: text("semantic_phase")
|
|
904
|
+
// "exploration" | "implementation" | "testing" | "unknown"
|
|
905
|
+
});
|
|
906
|
+
var turnContent = sqliteTable("turn_content", {
|
|
907
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
908
|
+
eventId: integer("event_id").notNull().references(() => tokenEvents.id, { onDelete: "cascade" }),
|
|
909
|
+
role: text("role").notNull(),
|
|
910
|
+
// 'user' | 'assistant'
|
|
911
|
+
content: text("content").notNull(),
|
|
912
|
+
// JSON stringified
|
|
913
|
+
contentHash: text("content_hash").notNull(),
|
|
914
|
+
byteSize: integer("byte_size").notNull()
|
|
915
|
+
});
|
|
916
|
+
var memories = sqliteTable("memories", {
|
|
917
|
+
id: text("id").primaryKey(),
|
|
918
|
+
title: text("title").notNull(),
|
|
919
|
+
type: text("type").notNull(),
|
|
920
|
+
// bugfix | decision | architecture | discovery | pattern | config | preference
|
|
921
|
+
scope: text("scope").notNull().default("project"),
|
|
922
|
+
// project | personal
|
|
923
|
+
topicKey: text("topic_key"),
|
|
924
|
+
description: text("description"),
|
|
925
|
+
content: text("content").notNull(),
|
|
926
|
+
projectPath: text("project_path"),
|
|
927
|
+
filePath: text("file_path"),
|
|
928
|
+
sessionId: text("session_id"),
|
|
929
|
+
accessCount: integer("access_count").notNull().default(0),
|
|
930
|
+
lastAccessedAt: integer("last_accessed_at"),
|
|
931
|
+
createdAt: integer("created_at").notNull(),
|
|
932
|
+
updatedAt: integer("updated_at").notNull()
|
|
933
|
+
});
|
|
934
|
+
var syncState = sqliteTable("sync_state", {
|
|
935
|
+
filePath: text("file_path").primaryKey(),
|
|
936
|
+
lastByteOffset: integer("last_byte_offset").notNull().default(0),
|
|
937
|
+
lastModified: integer("last_modified").notNull().default(0)
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
// src/server/db/db.ts
|
|
941
|
+
var DEFAULT_DB_DIR2 = join5(homedir5(), ".claude", "state", "claude-master-toolkit");
|
|
942
|
+
var DEFAULT_DB_PATH2 = join5(DEFAULT_DB_DIR2, "ctk.sqlite");
|
|
943
|
+
function resolveDbPath2() {
|
|
944
|
+
return process.env["CTK_DB_PATH"] ?? DEFAULT_DB_PATH2;
|
|
945
|
+
}
|
|
946
|
+
var _db = null;
|
|
947
|
+
var _sqlite = null;
|
|
948
|
+
function getDb() {
|
|
949
|
+
if (_db) return _db;
|
|
950
|
+
const dbPath = resolveDbPath2();
|
|
951
|
+
mkdirSync3(join5(dbPath, ".."), { recursive: true });
|
|
952
|
+
_sqlite = new Database2(dbPath);
|
|
953
|
+
_sqlite.pragma("journal_mode = WAL");
|
|
954
|
+
_sqlite.pragma("foreign_keys = ON");
|
|
955
|
+
_db = drizzle(_sqlite, { schema: schema_exports });
|
|
956
|
+
return _db;
|
|
957
|
+
}
|
|
958
|
+
function closeDb() {
|
|
959
|
+
if (_sqlite) {
|
|
960
|
+
_sqlite.close();
|
|
961
|
+
_sqlite = null;
|
|
962
|
+
_db = null;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
process.on("SIGINT", () => {
|
|
966
|
+
closeDb();
|
|
967
|
+
process.exit(0);
|
|
968
|
+
});
|
|
969
|
+
process.on("SIGTERM", () => {
|
|
970
|
+
closeDb();
|
|
971
|
+
process.exit(0);
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
// src/server/parser/sync.ts
|
|
975
|
+
async function syncFile(filePath) {
|
|
976
|
+
if (!existsSync6(filePath)) return;
|
|
977
|
+
const db = getDb();
|
|
978
|
+
const stat = statSync2(filePath);
|
|
979
|
+
const existing = db.select().from(syncState).where(eq(syncState.filePath, filePath)).get();
|
|
980
|
+
if (existing?.lastModified === stat.mtimeMs) return;
|
|
981
|
+
const allEvents = await parseJsonlFile(filePath);
|
|
982
|
+
const meta = extractSessionMeta(allEvents);
|
|
983
|
+
const tokenEvts = extractTokenEvents(allEvents);
|
|
984
|
+
const totalTokens = tokenEvts.reduce(
|
|
985
|
+
(acc, e) => ({
|
|
986
|
+
inputTokens: acc.inputTokens + e.usage.inputTokens,
|
|
987
|
+
outputTokens: acc.outputTokens + e.usage.outputTokens,
|
|
988
|
+
cacheReadTokens: acc.cacheReadTokens + e.usage.cacheReadTokens,
|
|
989
|
+
cacheCreationTokens: acc.cacheCreationTokens + e.usage.cacheCreationTokens
|
|
990
|
+
}),
|
|
991
|
+
{ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }
|
|
992
|
+
);
|
|
993
|
+
const totalCost = tokenEvts.reduce(
|
|
994
|
+
(acc, e) => acc + computeCost(e.model, e.usage),
|
|
995
|
+
0
|
|
996
|
+
);
|
|
997
|
+
const projectDir = basename3(dirname2(filePath));
|
|
998
|
+
const projectPath = projectDir.replace(/^-/, "/").replace(/-/g, "/");
|
|
999
|
+
db.insert(sessions).values({
|
|
1000
|
+
id: meta.sessionId,
|
|
1001
|
+
projectPath,
|
|
1002
|
+
startedAt: new Date(meta.startedAt).getTime(),
|
|
1003
|
+
lastActiveAt: new Date(meta.lastActiveAt).getTime(),
|
|
1004
|
+
primaryModel: meta.primaryModel,
|
|
1005
|
+
gitBranch: meta.gitBranch,
|
|
1006
|
+
version: meta.version,
|
|
1007
|
+
turnCount: meta.turnCount,
|
|
1008
|
+
totalInputTokens: totalTokens.inputTokens,
|
|
1009
|
+
totalOutputTokens: totalTokens.outputTokens,
|
|
1010
|
+
totalCacheReadTokens: totalTokens.cacheReadTokens,
|
|
1011
|
+
totalCacheCreationTokens: totalTokens.cacheCreationTokens,
|
|
1012
|
+
totalCostUsd: totalCost,
|
|
1013
|
+
jsonlFile: filePath
|
|
1014
|
+
}).onConflictDoUpdate({
|
|
1015
|
+
target: sessions.id,
|
|
1016
|
+
set: {
|
|
1017
|
+
lastActiveAt: new Date(meta.lastActiveAt).getTime(),
|
|
1018
|
+
primaryModel: meta.primaryModel,
|
|
1019
|
+
turnCount: meta.turnCount,
|
|
1020
|
+
totalInputTokens: totalTokens.inputTokens,
|
|
1021
|
+
totalOutputTokens: totalTokens.outputTokens,
|
|
1022
|
+
totalCacheReadTokens: totalTokens.cacheReadTokens,
|
|
1023
|
+
totalCacheCreationTokens: totalTokens.cacheCreationTokens,
|
|
1024
|
+
totalCostUsd: totalCost
|
|
1025
|
+
}
|
|
1026
|
+
}).run();
|
|
1027
|
+
db.delete(tokenEvents).where(eq(tokenEvents.sessionId, meta.sessionId)).run();
|
|
1028
|
+
const enrichedEvts = extractEnrichedTokenEvents(allEvents);
|
|
1029
|
+
for (const evt of enrichedEvts) {
|
|
1030
|
+
const cost = computeCost(evt.model, evt.usage);
|
|
1031
|
+
const inserted = db.insert(tokenEvents).values({
|
|
1032
|
+
sessionId: meta.sessionId,
|
|
1033
|
+
timestamp: new Date(evt.timestamp).getTime(),
|
|
1034
|
+
model: evt.model,
|
|
1035
|
+
inputTokens: evt.usage.inputTokens,
|
|
1036
|
+
outputTokens: evt.usage.outputTokens,
|
|
1037
|
+
cacheReadTokens: evt.usage.cacheReadTokens,
|
|
1038
|
+
cacheCreationTokens: evt.usage.cacheCreationTokens,
|
|
1039
|
+
costUsd: cost,
|
|
1040
|
+
toolsUsed: JSON.stringify(evt.toolsUsed),
|
|
1041
|
+
stopReason: evt.stopReason,
|
|
1042
|
+
isSidechain: evt.isSidechain,
|
|
1043
|
+
parentUuid: evt.parentUuid,
|
|
1044
|
+
semanticPhase: evt.semanticPhase
|
|
1045
|
+
}).returning({ id: tokenEvents.id }).get();
|
|
1046
|
+
if (inserted && evt.content) {
|
|
1047
|
+
db.insert(turnContent).values({
|
|
1048
|
+
eventId: inserted.id,
|
|
1049
|
+
role: "assistant",
|
|
1050
|
+
content: evt.content,
|
|
1051
|
+
contentHash: evt.contentHash,
|
|
1052
|
+
byteSize: Buffer.byteLength(evt.content, "utf-8")
|
|
1053
|
+
}).run();
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
db.insert(syncState).values({ filePath, lastByteOffset: 0, lastModified: stat.mtimeMs }).onConflictDoUpdate({
|
|
1057
|
+
target: syncState.filePath,
|
|
1058
|
+
set: { lastByteOffset: 0, lastModified: stat.mtimeMs }
|
|
1059
|
+
}).run();
|
|
1060
|
+
}
|
|
1061
|
+
async function syncAll() {
|
|
1062
|
+
const projectDirs = listProjectDirs();
|
|
1063
|
+
let fileCount = 0;
|
|
1064
|
+
for (const dir of projectDirs) {
|
|
1065
|
+
const files = listSessionFiles(dir);
|
|
1066
|
+
for (const file of files) {
|
|
1067
|
+
await syncFile(file);
|
|
1068
|
+
fileCount++;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
const db = getDb();
|
|
1072
|
+
const sessionCount = db.select().from(sessions).all().length;
|
|
1073
|
+
return { files: fileCount, sessions: sessionCount };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// src/server/parser/watcher.ts
|
|
1077
|
+
import { watch } from "chokidar";
|
|
1078
|
+
import { join as join6 } from "path";
|
|
1079
|
+
import { homedir as homedir6 } from "os";
|
|
1080
|
+
var CLAUDE_PROJECTS_DIR2 = join6(homedir6(), ".claude", "projects");
|
|
1081
|
+
function startWatcher(callback) {
|
|
1082
|
+
const watcher = watch(join6(CLAUDE_PROJECTS_DIR2, "**", "*.jsonl"), {
|
|
1083
|
+
persistent: true,
|
|
1084
|
+
ignoreInitial: true,
|
|
1085
|
+
awaitWriteFinish: {
|
|
1086
|
+
stabilityThreshold: 500,
|
|
1087
|
+
pollInterval: 100
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
watcher.on("change", async (filePath) => {
|
|
1091
|
+
try {
|
|
1092
|
+
await syncFile(filePath);
|
|
1093
|
+
callback?.("synced", filePath);
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
console.error(`[watcher] Error syncing ${filePath}:`, err);
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
watcher.on("add", async (filePath) => {
|
|
1099
|
+
try {
|
|
1100
|
+
await syncFile(filePath);
|
|
1101
|
+
callback?.("synced", filePath);
|
|
1102
|
+
} catch (err) {
|
|
1103
|
+
console.error(`[watcher] Error syncing new file ${filePath}:`, err);
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
return watcher;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// src/server/parser/engram-import.ts
|
|
1110
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync5, existsSync as existsSync7 } from "fs";
|
|
1111
|
+
import { join as join7 } from "path";
|
|
1112
|
+
import { randomUUID } from "crypto";
|
|
1113
|
+
import { eq as eq2, and } from "drizzle-orm";
|
|
1114
|
+
function parseMemoryFile(filePath) {
|
|
1115
|
+
const raw = readFileSync5(filePath, "utf-8");
|
|
1116
|
+
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
1117
|
+
if (!fmMatch) {
|
|
1118
|
+
return { content: raw };
|
|
1119
|
+
}
|
|
1120
|
+
const frontmatter = fmMatch[1];
|
|
1121
|
+
const content = fmMatch[2].trim();
|
|
1122
|
+
const fields = {};
|
|
1123
|
+
for (const line of frontmatter.split("\n")) {
|
|
1124
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
1125
|
+
if (match) {
|
|
1126
|
+
fields[match[1]] = match[2].trim();
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return {
|
|
1130
|
+
name: fields["name"],
|
|
1131
|
+
description: fields["description"],
|
|
1132
|
+
type: fields["type"],
|
|
1133
|
+
topicKey: fields["topicKey"],
|
|
1134
|
+
originSessionId: fields["originSessionId"],
|
|
1135
|
+
content
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
function mapMemoryType(type) {
|
|
1139
|
+
const mapping = {
|
|
1140
|
+
user: "preference",
|
|
1141
|
+
feedback: "preference",
|
|
1142
|
+
project: "decision",
|
|
1143
|
+
reference: "discovery",
|
|
1144
|
+
bugfix: "bugfix",
|
|
1145
|
+
decision: "decision",
|
|
1146
|
+
architecture: "architecture",
|
|
1147
|
+
discovery: "discovery",
|
|
1148
|
+
pattern: "pattern",
|
|
1149
|
+
config: "config",
|
|
1150
|
+
preference: "preference"
|
|
1151
|
+
};
|
|
1152
|
+
return mapping[type ?? ""] ?? "discovery";
|
|
1153
|
+
}
|
|
1154
|
+
async function importAllMemories() {
|
|
1155
|
+
const db = getDb();
|
|
1156
|
+
const projectDirs = listProjectDirs();
|
|
1157
|
+
let imported = 0;
|
|
1158
|
+
let skipped = 0;
|
|
1159
|
+
for (const dir of projectDirs) {
|
|
1160
|
+
const memoryDir = join7(dir, "memory");
|
|
1161
|
+
if (!existsSync7(memoryDir)) continue;
|
|
1162
|
+
const files = readdirSync2(memoryDir).filter(
|
|
1163
|
+
(f) => f.endsWith(".md") && f !== "MEMORY.md"
|
|
1164
|
+
);
|
|
1165
|
+
const dirName = dir.split("/").pop() ?? "";
|
|
1166
|
+
const projectPath = dirName.replace(/^-/, "/").replace(/-/g, "/");
|
|
1167
|
+
for (const file of files) {
|
|
1168
|
+
const filePath = join7(memoryDir, file);
|
|
1169
|
+
const parsed = parseMemoryFile(filePath);
|
|
1170
|
+
if (!parsed) {
|
|
1171
|
+
skipped++;
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
const title = parsed.name ?? file.replace(".md", "");
|
|
1175
|
+
const now = Date.now();
|
|
1176
|
+
try {
|
|
1177
|
+
const existing = db.select({ id: memories.id }).from(memories).where(and(eq2(memories.title, title), eq2(memories.projectPath, projectPath))).get();
|
|
1178
|
+
if (existing) {
|
|
1179
|
+
db.update(memories).set({
|
|
1180
|
+
type: mapMemoryType(parsed.type),
|
|
1181
|
+
description: parsed.description,
|
|
1182
|
+
content: parsed.content,
|
|
1183
|
+
filePath,
|
|
1184
|
+
topicKey: parsed.topicKey,
|
|
1185
|
+
sessionId: parsed.originSessionId,
|
|
1186
|
+
updatedAt: now
|
|
1187
|
+
}).where(eq2(memories.id, existing.id)).run();
|
|
1188
|
+
} else {
|
|
1189
|
+
const id = randomUUID();
|
|
1190
|
+
db.insert(memories).values({
|
|
1191
|
+
id,
|
|
1192
|
+
title,
|
|
1193
|
+
type: mapMemoryType(parsed.type),
|
|
1194
|
+
scope: "project",
|
|
1195
|
+
description: parsed.description,
|
|
1196
|
+
content: parsed.content,
|
|
1197
|
+
filePath,
|
|
1198
|
+
topicKey: parsed.topicKey,
|
|
1199
|
+
projectPath,
|
|
1200
|
+
sessionId: parsed.originSessionId,
|
|
1201
|
+
createdAt: now,
|
|
1202
|
+
updatedAt: now
|
|
1203
|
+
}).run();
|
|
1204
|
+
}
|
|
1205
|
+
imported++;
|
|
1206
|
+
} catch (e) {
|
|
1207
|
+
console.error(`[engram-import] Error processing ${filePath}:`, e);
|
|
1208
|
+
skipped++;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return { imported, skipped };
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// src/server/routes/sessions.ts
|
|
1216
|
+
import { desc, eq as eq3 } from "drizzle-orm";
|
|
1217
|
+
import { execSync as execSync2 } from "child_process";
|
|
1218
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1219
|
+
async function sessionsRoutes(app) {
|
|
1220
|
+
app.get("/sessions", async (_req, reply) => {
|
|
1221
|
+
const db = getDb();
|
|
1222
|
+
const rows = db.select().from(sessions).orderBy(desc(sessions.lastActiveAt)).limit(100).all();
|
|
1223
|
+
const data = rows.map((r) => ({
|
|
1224
|
+
id: r.id,
|
|
1225
|
+
projectPath: r.projectPath,
|
|
1226
|
+
startedAt: r.startedAt,
|
|
1227
|
+
lastActiveAt: r.lastActiveAt,
|
|
1228
|
+
primaryModel: r.primaryModel,
|
|
1229
|
+
gitBranch: r.gitBranch,
|
|
1230
|
+
turnCount: r.turnCount,
|
|
1231
|
+
tokens: {
|
|
1232
|
+
inputTokens: r.totalInputTokens,
|
|
1233
|
+
outputTokens: r.totalOutputTokens,
|
|
1234
|
+
cacheReadTokens: r.totalCacheReadTokens,
|
|
1235
|
+
cacheCreationTokens: r.totalCacheCreationTokens
|
|
1236
|
+
},
|
|
1237
|
+
costUsd: r.totalCostUsd
|
|
1238
|
+
}));
|
|
1239
|
+
return reply.send(data);
|
|
1240
|
+
});
|
|
1241
|
+
app.get("/sessions/:id", async (req, reply) => {
|
|
1242
|
+
const db = getDb();
|
|
1243
|
+
const session = db.select().from(sessions).where(eq3(sessions.id, req.params.id)).get();
|
|
1244
|
+
if (!session) {
|
|
1245
|
+
return reply.status(404).send({ error: "Session not found" });
|
|
1246
|
+
}
|
|
1247
|
+
const events = db.select().from(tokenEvents).where(eq3(tokenEvents.sessionId, req.params.id)).orderBy(tokenEvents.timestamp).all();
|
|
1248
|
+
const modelBreakdown = {};
|
|
1249
|
+
for (const e of events) {
|
|
1250
|
+
const key = e.model;
|
|
1251
|
+
if (!modelBreakdown[key]) {
|
|
1252
|
+
modelBreakdown[key] = {
|
|
1253
|
+
inputTokens: 0,
|
|
1254
|
+
outputTokens: 0,
|
|
1255
|
+
cacheReadTokens: 0,
|
|
1256
|
+
cacheCreationTokens: 0,
|
|
1257
|
+
costUsd: 0,
|
|
1258
|
+
turns: 0
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
modelBreakdown[key].inputTokens += e.inputTokens;
|
|
1262
|
+
modelBreakdown[key].outputTokens += e.outputTokens;
|
|
1263
|
+
modelBreakdown[key].cacheReadTokens += e.cacheReadTokens;
|
|
1264
|
+
modelBreakdown[key].cacheCreationTokens += e.cacheCreationTokens;
|
|
1265
|
+
modelBreakdown[key].costUsd += e.costUsd;
|
|
1266
|
+
modelBreakdown[key].turns++;
|
|
1267
|
+
}
|
|
1268
|
+
return reply.send({
|
|
1269
|
+
id: session.id,
|
|
1270
|
+
projectPath: session.projectPath,
|
|
1271
|
+
startedAt: session.startedAt,
|
|
1272
|
+
lastActiveAt: session.lastActiveAt,
|
|
1273
|
+
primaryModel: session.primaryModel,
|
|
1274
|
+
gitBranch: session.gitBranch,
|
|
1275
|
+
version: session.version,
|
|
1276
|
+
turnCount: session.turnCount,
|
|
1277
|
+
tokens: {
|
|
1278
|
+
inputTokens: session.totalInputTokens,
|
|
1279
|
+
outputTokens: session.totalOutputTokens,
|
|
1280
|
+
cacheReadTokens: session.totalCacheReadTokens,
|
|
1281
|
+
cacheCreationTokens: session.totalCacheCreationTokens
|
|
1282
|
+
},
|
|
1283
|
+
costUsd: session.totalCostUsd,
|
|
1284
|
+
events,
|
|
1285
|
+
modelBreakdown
|
|
1286
|
+
});
|
|
1287
|
+
});
|
|
1288
|
+
app.get("/sessions/:id/git-stats", async (req, reply) => {
|
|
1289
|
+
const db = getDb();
|
|
1290
|
+
const session = db.select().from(sessions).where(eq3(sessions.id, req.params.id)).get();
|
|
1291
|
+
if (!session) {
|
|
1292
|
+
return reply.status(404).send({ error: "Session not found" });
|
|
1293
|
+
}
|
|
1294
|
+
const projectPath = session.projectPath;
|
|
1295
|
+
if (!existsSync8(projectPath)) {
|
|
1296
|
+
return reply.send({ insertions: 0, deletions: 0, filesChanged: 0, available: false });
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
const startISO = new Date(session.startedAt).toISOString();
|
|
1300
|
+
const endISO = new Date(session.lastActiveAt + 5 * 60 * 1e3).toISOString();
|
|
1301
|
+
const output2 = execSync2(
|
|
1302
|
+
`git -C "${projectPath}" log --numstat --format="" --after="${startISO}" --before="${endISO}" 2>/dev/null || echo ""`,
|
|
1303
|
+
{ encoding: "utf-8", stdio: "pipe" }
|
|
1304
|
+
);
|
|
1305
|
+
let insertions = 0;
|
|
1306
|
+
let deletions = 0;
|
|
1307
|
+
const filesChanged = /* @__PURE__ */ new Set();
|
|
1308
|
+
for (const line of output2.split("\n")) {
|
|
1309
|
+
const parts = line.trim().split(" ");
|
|
1310
|
+
if (parts.length >= 3) {
|
|
1311
|
+
const ins = parseInt(parts[0], 10);
|
|
1312
|
+
const dels = parseInt(parts[1], 10);
|
|
1313
|
+
if (!isNaN(ins) && !isNaN(dels)) {
|
|
1314
|
+
insertions += ins;
|
|
1315
|
+
deletions += dels;
|
|
1316
|
+
filesChanged.add(parts[2] || "");
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return reply.send({
|
|
1321
|
+
insertions,
|
|
1322
|
+
deletions,
|
|
1323
|
+
filesChanged: filesChanged.size,
|
|
1324
|
+
available: true
|
|
1325
|
+
});
|
|
1326
|
+
} catch (e) {
|
|
1327
|
+
console.error(`[sessions] Error getting git stats for ${projectPath}:`, e);
|
|
1328
|
+
return reply.send({ insertions: 0, deletions: 0, filesChanged: 0, available: false });
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// src/server/routes/stats.ts
|
|
1334
|
+
import { desc as desc2, sql as sql2, gte } from "drizzle-orm";
|
|
1335
|
+
async function statsRoutes(app) {
|
|
1336
|
+
app.get("/stats/current", async (_req, reply) => {
|
|
1337
|
+
const db = getDb();
|
|
1338
|
+
const latestSession = db.select().from(sessions).orderBy(desc2(sessions.lastActiveAt)).limit(1).get();
|
|
1339
|
+
const totalSessions = db.select({ count: sql2`count(*)` }).from(sessions).get();
|
|
1340
|
+
const totalCost = db.select({ total: sql2`sum(total_cost_usd)` }).from(sessions).get();
|
|
1341
|
+
const totalTurns = db.select({ count: sql2`sum(turn_count)` }).from(sessions).get();
|
|
1342
|
+
const sessionCount = totalSessions?.count ?? 0;
|
|
1343
|
+
const totalCostUsd = totalCost?.total ?? 0;
|
|
1344
|
+
const totalTurnCount = totalTurns?.count ?? 0;
|
|
1345
|
+
return reply.send({
|
|
1346
|
+
latestSession: latestSession ? {
|
|
1347
|
+
id: latestSession.id,
|
|
1348
|
+
projectPath: latestSession.projectPath,
|
|
1349
|
+
primaryModel: latestSession.primaryModel,
|
|
1350
|
+
costUsd: latestSession.totalCostUsd,
|
|
1351
|
+
turnCount: latestSession.turnCount,
|
|
1352
|
+
lastActiveAt: latestSession.lastActiveAt
|
|
1353
|
+
} : null,
|
|
1354
|
+
totalSessions: sessionCount,
|
|
1355
|
+
totalCostUsd,
|
|
1356
|
+
totalTurns: totalTurnCount,
|
|
1357
|
+
avgCostPerSession: sessionCount > 0 ? totalCostUsd / sessionCount : 0
|
|
1358
|
+
});
|
|
1359
|
+
});
|
|
1360
|
+
app.get("/stats/timeline", async (_req, reply) => {
|
|
1361
|
+
const db = getDb();
|
|
1362
|
+
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1e3;
|
|
1363
|
+
const rows = db.select({
|
|
1364
|
+
date: sql2`date(timestamp / 1000, 'unixepoch')`,
|
|
1365
|
+
costUsd: sql2`sum(cost_usd)`,
|
|
1366
|
+
inputTokens: sql2`sum(input_tokens)`,
|
|
1367
|
+
outputTokens: sql2`sum(output_tokens)`,
|
|
1368
|
+
sessions: sql2`count(distinct session_id)`
|
|
1369
|
+
}).from(tokenEvents).where(gte(tokenEvents.timestamp, sevenDaysAgo)).groupBy(sql2`date(timestamp / 1000, 'unixepoch')`).orderBy(sql2`date(timestamp / 1000, 'unixepoch')`).all();
|
|
1370
|
+
return reply.send(rows);
|
|
1371
|
+
});
|
|
1372
|
+
app.get("/stats/models", async (_req, reply) => {
|
|
1373
|
+
const db = getDb();
|
|
1374
|
+
const rows = db.select({
|
|
1375
|
+
model: tokenEvents.model,
|
|
1376
|
+
totalInput: sql2`sum(input_tokens)`,
|
|
1377
|
+
totalOutput: sql2`sum(output_tokens)`,
|
|
1378
|
+
totalCacheRead: sql2`sum(cache_read_tokens)`,
|
|
1379
|
+
totalCacheCreation: sql2`sum(cache_creation_tokens)`,
|
|
1380
|
+
costUsd: sql2`sum(cost_usd)`,
|
|
1381
|
+
turns: sql2`count(*)`,
|
|
1382
|
+
sessionCount: sql2`count(distinct session_id)`
|
|
1383
|
+
}).from(tokenEvents).groupBy(tokenEvents.model).all();
|
|
1384
|
+
const totalCost = rows.reduce((acc, r) => acc + (r.costUsd ?? 0), 0);
|
|
1385
|
+
const data = rows.map((r) => ({
|
|
1386
|
+
model: r.model,
|
|
1387
|
+
modelKey: resolveModelKey(r.model),
|
|
1388
|
+
totalTokens: (r.totalInput ?? 0) + (r.totalOutput ?? 0) + (r.totalCacheRead ?? 0) + (r.totalCacheCreation ?? 0),
|
|
1389
|
+
tokens: {
|
|
1390
|
+
input: r.totalInput ?? 0,
|
|
1391
|
+
output: r.totalOutput ?? 0,
|
|
1392
|
+
cacheRead: r.totalCacheRead ?? 0,
|
|
1393
|
+
cacheCreation: r.totalCacheCreation ?? 0
|
|
1394
|
+
},
|
|
1395
|
+
costUsd: r.costUsd ?? 0,
|
|
1396
|
+
turns: r.turns ?? 0,
|
|
1397
|
+
sessionCount: r.sessionCount ?? 0,
|
|
1398
|
+
percentage: totalCost > 0 ? (r.costUsd ?? 0) / totalCost * 100 : 0
|
|
1399
|
+
}));
|
|
1400
|
+
return reply.send(data);
|
|
1401
|
+
});
|
|
1402
|
+
app.get("/stats/efficiency", async (req, reply) => {
|
|
1403
|
+
const db = getDb();
|
|
1404
|
+
const filter = req.query.sessionId ? sql2`AND session_id = ${req.query.sessionId}` : sql2``;
|
|
1405
|
+
const rows = db.all(sql2`
|
|
1406
|
+
WITH tool_rows AS (
|
|
1407
|
+
SELECT
|
|
1408
|
+
json_each.value AS tool,
|
|
1409
|
+
input_tokens,
|
|
1410
|
+
output_tokens,
|
|
1411
|
+
cost_usd
|
|
1412
|
+
FROM token_events, json_each(tools_used)
|
|
1413
|
+
WHERE tools_used IS NOT NULL ${filter}
|
|
1414
|
+
)
|
|
1415
|
+
SELECT
|
|
1416
|
+
tool,
|
|
1417
|
+
COUNT(*) AS count,
|
|
1418
|
+
ROUND(AVG(input_tokens), 0) AS avgInput,
|
|
1419
|
+
ROUND(AVG(output_tokens), 0) AS avgOutput,
|
|
1420
|
+
ROUND(AVG(cost_usd), 6) AS avgCost
|
|
1421
|
+
FROM tool_rows
|
|
1422
|
+
GROUP BY tool
|
|
1423
|
+
ORDER BY count DESC
|
|
1424
|
+
`);
|
|
1425
|
+
const overall = db.get(sql2`
|
|
1426
|
+
SELECT
|
|
1427
|
+
ROUND(AVG(input_tokens + output_tokens), 0) AS avgTokens,
|
|
1428
|
+
ROUND(AVG(cost_usd), 6) AS avgCost
|
|
1429
|
+
FROM token_events
|
|
1430
|
+
WHERE tools_used IS NOT NULL ${filter}
|
|
1431
|
+
`);
|
|
1432
|
+
return reply.send({
|
|
1433
|
+
perTool: Object.fromEntries(
|
|
1434
|
+
rows.map((r) => [r.tool, { count: r.count, avgInputTokens: r.avgInput, avgOutputTokens: r.avgOutput, avgCostUsd: r.avgCost }])
|
|
1435
|
+
),
|
|
1436
|
+
overall: {
|
|
1437
|
+
avgTokensPerTurn: overall?.avgTokens ?? 0,
|
|
1438
|
+
avgCostPerTurn: overall?.avgCost ?? 0
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
});
|
|
1442
|
+
app.get("/stats/phases", async (req, reply) => {
|
|
1443
|
+
const db = getDb();
|
|
1444
|
+
const filter = req.query.sessionId ? sql2`AND session_id = ${req.query.sessionId}` : sql2``;
|
|
1445
|
+
const rows = db.all(sql2`
|
|
1446
|
+
SELECT
|
|
1447
|
+
semantic_phase AS phase,
|
|
1448
|
+
COUNT(*) AS turns,
|
|
1449
|
+
SUM(input_tokens + output_tokens) AS tokens
|
|
1450
|
+
FROM token_events
|
|
1451
|
+
WHERE semantic_phase IS NOT NULL ${filter}
|
|
1452
|
+
GROUP BY semantic_phase
|
|
1453
|
+
`);
|
|
1454
|
+
const totalTurns = rows.reduce((acc, r) => acc + r.turns, 0);
|
|
1455
|
+
const data = Object.fromEntries(
|
|
1456
|
+
rows.map((r) => [
|
|
1457
|
+
r.phase,
|
|
1458
|
+
{
|
|
1459
|
+
turns: r.turns,
|
|
1460
|
+
pct: totalTurns > 0 ? Math.round(r.turns / totalTurns * 100) : 0,
|
|
1461
|
+
tokens: r.tokens
|
|
1462
|
+
}
|
|
1463
|
+
])
|
|
1464
|
+
);
|
|
1465
|
+
return reply.send(data);
|
|
1466
|
+
});
|
|
1467
|
+
app.get("/stats/tools", async (req, reply) => {
|
|
1468
|
+
const db = getDb();
|
|
1469
|
+
const filter = req.query.sessionId ? sql2`AND session_id = ${req.query.sessionId}` : sql2``;
|
|
1470
|
+
const freqRows = db.all(sql2`
|
|
1471
|
+
SELECT json_each.value AS tool, COUNT(*) AS count
|
|
1472
|
+
FROM token_events, json_each(tools_used)
|
|
1473
|
+
WHERE tools_used IS NOT NULL ${filter}
|
|
1474
|
+
GROUP BY tool
|
|
1475
|
+
ORDER BY count DESC
|
|
1476
|
+
`);
|
|
1477
|
+
const comboRows = db.all(sql2`
|
|
1478
|
+
SELECT tools_used AS combo, COUNT(*) AS count
|
|
1479
|
+
FROM token_events
|
|
1480
|
+
WHERE tools_used IS NOT NULL
|
|
1481
|
+
AND json_array_length(tools_used) >= 2
|
|
1482
|
+
${filter}
|
|
1483
|
+
GROUP BY tools_used
|
|
1484
|
+
ORDER BY count DESC
|
|
1485
|
+
LIMIT 10
|
|
1486
|
+
`);
|
|
1487
|
+
return reply.send({
|
|
1488
|
+
frequency: Object.fromEntries(freqRows.map((r) => [r.tool, r.count])),
|
|
1489
|
+
combos: comboRows.map((r) => ({
|
|
1490
|
+
tools: JSON.parse(r.combo),
|
|
1491
|
+
count: r.count
|
|
1492
|
+
}))
|
|
1493
|
+
});
|
|
1494
|
+
});
|
|
1495
|
+
app.get("/stats/score", async (req, reply) => {
|
|
1496
|
+
const db = getDb();
|
|
1497
|
+
const sessionFilter = req.query.sessionId ? sql2`WHERE session_id = ${req.query.sessionId}` : sql2``;
|
|
1498
|
+
const metrics = db.get(sql2`
|
|
1499
|
+
SELECT
|
|
1500
|
+
ROUND(AVG(input_tokens + output_tokens), 0) AS avgTokensPerTurn,
|
|
1501
|
+
COUNT(*) AS totalTurns,
|
|
1502
|
+
SUM(cache_read_tokens) AS totalCacheRead,
|
|
1503
|
+
SUM(input_tokens) AS totalInput,
|
|
1504
|
+
SUM(CASE WHEN semantic_phase = 'exploration' THEN 1 ELSE 0 END) AS explorationTurns,
|
|
1505
|
+
SUM(CASE WHEN semantic_phase = 'implementation' THEN 1 ELSE 0 END) AS implementationTurns,
|
|
1506
|
+
SUM(CASE WHEN semantic_phase = 'testing' THEN 1 ELSE 0 END) AS testingTurns
|
|
1507
|
+
FROM token_events
|
|
1508
|
+
${sessionFilter}
|
|
1509
|
+
`);
|
|
1510
|
+
if (!metrics || metrics.totalTurns === 0) {
|
|
1511
|
+
return reply.send({ score: 0, breakdown: { tokensPerTurn: 0, cacheHitRatio: 0, errorRecovery: 0, phaseBalance: 0 } });
|
|
1512
|
+
}
|
|
1513
|
+
const tptScore = Math.max(0, Math.min(100, Math.round(100 - (metrics.avgTokensPerTurn - 2e3) / 180)));
|
|
1514
|
+
const cacheRatio = metrics.totalInput > 0 ? metrics.totalCacheRead / metrics.totalInput : 0;
|
|
1515
|
+
const cacheScore = Math.round(Math.min(100, cacheRatio * 100));
|
|
1516
|
+
const total = metrics.totalTurns;
|
|
1517
|
+
const expPct = metrics.explorationTurns / total;
|
|
1518
|
+
const impPct = metrics.implementationTurns / total;
|
|
1519
|
+
const testPct = metrics.testingTurns / total;
|
|
1520
|
+
const distance = Math.abs(expPct - 0.2) + Math.abs(impPct - 0.6) + Math.abs(testPct - 0.2);
|
|
1521
|
+
const phaseScore = Math.round(Math.max(0, 100 - distance * 100));
|
|
1522
|
+
const errorRecovery = 75;
|
|
1523
|
+
const score = Math.round((tptScore + cacheScore + phaseScore + errorRecovery) / 4);
|
|
1524
|
+
return reply.send({
|
|
1525
|
+
sessionId: req.query.sessionId ?? "all",
|
|
1526
|
+
score,
|
|
1527
|
+
breakdown: {
|
|
1528
|
+
tokensPerTurn: tptScore,
|
|
1529
|
+
cacheHitRatio: cacheScore,
|
|
1530
|
+
errorRecovery,
|
|
1531
|
+
phaseBalance: phaseScore
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// src/server/routes/memories.ts
|
|
1538
|
+
import { and as and2, desc as desc3, eq as eq5, like, or } from "drizzle-orm";
|
|
1539
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1540
|
+
import { writeFileSync as writeFileSync2, existsSync as existsSync9 } from "fs";
|
|
1541
|
+
async function memoriesRoutes(app) {
|
|
1542
|
+
app.get(
|
|
1543
|
+
"/memories",
|
|
1544
|
+
async (req, reply) => {
|
|
1545
|
+
const db = getDb();
|
|
1546
|
+
const conditions = [];
|
|
1547
|
+
if (req.query.type) {
|
|
1548
|
+
conditions.push(eq5(memories.type, req.query.type));
|
|
1549
|
+
}
|
|
1550
|
+
if (req.query.project) {
|
|
1551
|
+
conditions.push(eq5(memories.projectPath, req.query.project));
|
|
1552
|
+
}
|
|
1553
|
+
if (req.query.search) {
|
|
1554
|
+
const s = `%${req.query.search}%`;
|
|
1555
|
+
conditions.push(or(like(memories.title, s), like(memories.content, s), like(memories.topicKey, s)));
|
|
1556
|
+
}
|
|
1557
|
+
const rows = db.select().from(memories).where(conditions.length > 0 ? and2(...conditions) : void 0).orderBy(desc3(memories.updatedAt)).all();
|
|
1558
|
+
return reply.send(rows);
|
|
1559
|
+
}
|
|
1560
|
+
);
|
|
1561
|
+
app.get("/memories/:id", async (req, reply) => {
|
|
1562
|
+
const db = getDb();
|
|
1563
|
+
const memory = db.select().from(memories).where(eq5(memories.id, req.params.id)).get();
|
|
1564
|
+
if (!memory) {
|
|
1565
|
+
return reply.status(404).send({ error: "Memory not found" });
|
|
1566
|
+
}
|
|
1567
|
+
db.update(memories).set({
|
|
1568
|
+
accessCount: (memory.accessCount || 0) + 1,
|
|
1569
|
+
lastAccessedAt: Date.now()
|
|
1570
|
+
}).where(eq5(memories.id, req.params.id)).run();
|
|
1571
|
+
return reply.send(memory);
|
|
1572
|
+
});
|
|
1573
|
+
app.post("/memories", async (req, reply) => {
|
|
1574
|
+
const db = getDb();
|
|
1575
|
+
const id = randomUUID2();
|
|
1576
|
+
const now = Date.now();
|
|
1577
|
+
db.insert(memories).values({
|
|
1578
|
+
id,
|
|
1579
|
+
title: req.body.title,
|
|
1580
|
+
type: req.body.type,
|
|
1581
|
+
scope: req.body.scope ?? "project",
|
|
1582
|
+
topicKey: req.body.topicKey,
|
|
1583
|
+
description: req.body.description,
|
|
1584
|
+
content: req.body.content,
|
|
1585
|
+
projectPath: req.body.projectPath,
|
|
1586
|
+
filePath: req.body.filePath,
|
|
1587
|
+
sessionId: req.body.sessionId,
|
|
1588
|
+
createdAt: now,
|
|
1589
|
+
updatedAt: now
|
|
1590
|
+
}).run();
|
|
1591
|
+
return reply.status(201).send({ id, created: true });
|
|
1592
|
+
});
|
|
1593
|
+
app.patch("/memories/:id", async (req, reply) => {
|
|
1594
|
+
const db = getDb();
|
|
1595
|
+
const existing = db.select().from(memories).where(eq5(memories.id, req.params.id)).get();
|
|
1596
|
+
if (!existing) {
|
|
1597
|
+
return reply.status(404).send({ error: "Memory not found" });
|
|
1598
|
+
}
|
|
1599
|
+
db.update(memories).set({
|
|
1600
|
+
...req.body,
|
|
1601
|
+
updatedAt: Date.now()
|
|
1602
|
+
}).where(eq5(memories.id, req.params.id)).run();
|
|
1603
|
+
return reply.send({ updated: true });
|
|
1604
|
+
});
|
|
1605
|
+
app.post("/memories/:id/sync", async (req, reply) => {
|
|
1606
|
+
const db = getDb();
|
|
1607
|
+
const memory = db.select().from(memories).where(eq5(memories.id, req.params.id)).get();
|
|
1608
|
+
if (!memory) {
|
|
1609
|
+
return reply.status(404).send({ error: "Memory not found" });
|
|
1610
|
+
}
|
|
1611
|
+
if (!memory.filePath) {
|
|
1612
|
+
return reply.status(400).send({ error: "No file path associated with this memory" });
|
|
1613
|
+
}
|
|
1614
|
+
if (!existsSync9(memory.filePath)) {
|
|
1615
|
+
return reply.status(400).send({ error: "File does not exist on disk" });
|
|
1616
|
+
}
|
|
1617
|
+
const frontmatter = [
|
|
1618
|
+
"---",
|
|
1619
|
+
`name: ${memory.title}`,
|
|
1620
|
+
`description: ${memory.description || ""}`,
|
|
1621
|
+
`type: ${memory.type}`,
|
|
1622
|
+
memory.topicKey ? `topicKey: ${memory.topicKey}` : null,
|
|
1623
|
+
memory.sessionId ? `originSessionId: ${memory.sessionId}` : null,
|
|
1624
|
+
"---"
|
|
1625
|
+
].filter(Boolean).join("\n");
|
|
1626
|
+
const content = `${frontmatter}
|
|
1627
|
+
|
|
1628
|
+
${memory.content}`;
|
|
1629
|
+
try {
|
|
1630
|
+
writeFileSync2(memory.filePath, content, "utf-8");
|
|
1631
|
+
return reply.send({ synced: true });
|
|
1632
|
+
} catch (e) {
|
|
1633
|
+
console.error(`[memories] Error syncing to ${memory.filePath}:`, e);
|
|
1634
|
+
return reply.status(500).send({ error: "Failed to write file" });
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
app.delete("/memories/:id", async (req, reply) => {
|
|
1638
|
+
const db = getDb();
|
|
1639
|
+
db.delete(memories).where(eq5(memories.id, req.params.id)).run();
|
|
1640
|
+
return reply.send({ deleted: true });
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// src/server/routes/health.ts
|
|
1645
|
+
import { sql as sql3 } from "drizzle-orm";
|
|
1646
|
+
async function healthRoutes(app) {
|
|
1647
|
+
app.get("/health", async (_req, reply) => {
|
|
1648
|
+
const db = getDb();
|
|
1649
|
+
const sessionCount = db.select({ count: sql3`count(*)` }).from(sessions).get();
|
|
1650
|
+
const eventCount = db.select({ count: sql3`count(*)` }).from(tokenEvents).get();
|
|
1651
|
+
const memoryCount = db.select({ count: sql3`count(*)` }).from(memories).get();
|
|
1652
|
+
return reply.send({
|
|
1653
|
+
status: "ok",
|
|
1654
|
+
timestamp: Date.now(),
|
|
1655
|
+
counts: {
|
|
1656
|
+
sessions: sessionCount?.count ?? 0,
|
|
1657
|
+
tokenEvents: eventCount?.count ?? 0,
|
|
1658
|
+
memories: memoryCount?.count ?? 0
|
|
1659
|
+
}
|
|
1660
|
+
});
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// src/server/index.ts
|
|
1665
|
+
var __dirname = dirname3(fileURLToPath(import.meta.url));
|
|
1666
|
+
async function startServer(port = 3200) {
|
|
1667
|
+
migrate();
|
|
1668
|
+
const app = Fastify({ logger: false });
|
|
1669
|
+
await app.register(cors, { origin: true });
|
|
1670
|
+
const publicDir = join8(__dirname, "public");
|
|
1671
|
+
if (existsSync10(publicDir)) {
|
|
1672
|
+
await app.register(fastifyStatic, {
|
|
1673
|
+
root: publicDir,
|
|
1674
|
+
prefix: "/",
|
|
1675
|
+
wildcard: false
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
await app.register(sessionsRoutes, { prefix: "/api" });
|
|
1679
|
+
await app.register(statsRoutes, { prefix: "/api" });
|
|
1680
|
+
await app.register(memoriesRoutes, { prefix: "/api" });
|
|
1681
|
+
await app.register(healthRoutes, { prefix: "/api" });
|
|
1682
|
+
app.setNotFoundHandler((_req, reply) => {
|
|
1683
|
+
const indexPath = join8(publicDir, "index.html");
|
|
1684
|
+
if (existsSync10(indexPath)) {
|
|
1685
|
+
return reply.sendFile("index.html");
|
|
1686
|
+
}
|
|
1687
|
+
return reply.status(404).send({ error: "Not found" });
|
|
1688
|
+
});
|
|
1689
|
+
startWatcher((event, filePath) => {
|
|
1690
|
+
if (event === "synced") {
|
|
1691
|
+
console.log(`[watcher] Synced: ${filePath}`);
|
|
1692
|
+
}
|
|
1693
|
+
});
|
|
1694
|
+
let attempts = 0;
|
|
1695
|
+
const maxAttempts = 5;
|
|
1696
|
+
while (attempts < maxAttempts) {
|
|
1697
|
+
try {
|
|
1698
|
+
await app.listen({ port, host: "0.0.0.0" });
|
|
1699
|
+
break;
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
if (err?.code === "EADDRINUSE" && attempts < maxAttempts - 1) {
|
|
1702
|
+
attempts++;
|
|
1703
|
+
const delay = Math.min(1e3 * Math.pow(2, attempts), 5e3);
|
|
1704
|
+
console.log(`[ctk] Port ${port} in use, retrying in ${delay}ms...`);
|
|
1705
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1706
|
+
} else {
|
|
1707
|
+
throw err;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
syncAll().then(
|
|
1712
|
+
(r) => console.log(`[ctk] Synced ${r.files} files, ${r.sessions} sessions`)
|
|
1713
|
+
).catch(console.error);
|
|
1714
|
+
importAllMemories().then((r) => {
|
|
1715
|
+
if (r.imported > 0)
|
|
1716
|
+
console.log(
|
|
1717
|
+
`[ctk] Imported ${r.imported} memories (${r.skipped} skipped)`
|
|
1718
|
+
);
|
|
1719
|
+
}).catch(console.error);
|
|
1720
|
+
}
|
|
1721
|
+
var isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
1722
|
+
if (isDirectRun) {
|
|
1723
|
+
startServer().catch((err) => {
|
|
1724
|
+
console.error("[ctk] Failed to start server:", err);
|
|
1725
|
+
process.exit(1);
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
|
|
628
1729
|
// src/cli/commands/dashboard.ts
|
|
629
|
-
var SERVER_MODULE = new URL("./server.js", import.meta.url).href;
|
|
630
1730
|
async function dashboardCommand(options) {
|
|
631
1731
|
const port = Number(options.port ?? 3200);
|
|
632
1732
|
const spin = spinner("Starting ctk server...");
|
|
633
1733
|
try {
|
|
634
|
-
const { startServer } = await import(SERVER_MODULE);
|
|
635
1734
|
await startServer(port);
|
|
636
1735
|
spin.succeed("Server started");
|
|
637
1736
|
const output2 = [
|
|
@@ -653,7 +1752,7 @@ async function dashboardCommand(options) {
|
|
|
653
1752
|
}
|
|
654
1753
|
|
|
655
1754
|
// src/cli/index.ts
|
|
656
|
-
var VERSION = "1.
|
|
1755
|
+
var VERSION = "0.1.2";
|
|
657
1756
|
var program = new Command().name("ctk").description(
|
|
658
1757
|
"Claude Master Toolkit \u2014 token-efficient CLI + metrics dashboard"
|
|
659
1758
|
).version(VERSION).option("--json", "Output in JSON format (AI-friendly)").hook("preAction", (thisCommand) => {
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-master-toolkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Token-efficient CLI + metrics dashboard for Claude Code. Measure real cost, cut context bloat, and keep sessions cheap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"ctk": "
|
|
7
|
+
"ctk": "./bin/ctk.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
"test:watch": "vitest",
|
|
23
23
|
"db:generate": "drizzle-kit generate",
|
|
24
24
|
"db:migrate": "tsx src/server/db/migrate.ts",
|
|
25
|
+
"postinstall": "chmod +x bin/ctk.js",
|
|
25
26
|
"prepublishOnly": "npm run build",
|
|
26
|
-
"reinstall": "./uninstall.sh && npm install && npm run db:migrate && npm run build && ./install.sh"
|
|
27
|
+
"reinstall": "./uninstall.sh && npm install && npm run db:migrate && npm run build && ./install.sh && npm link"
|
|
27
28
|
},
|
|
28
29
|
"keywords": [
|
|
29
30
|
"claude",
|
|
@@ -92,5 +93,11 @@
|
|
|
92
93
|
"typescript": "^5.8.3",
|
|
93
94
|
"vite": "^6.3.4",
|
|
94
95
|
"vitest": "^3.1.3"
|
|
96
|
+
},
|
|
97
|
+
"exports": {
|
|
98
|
+
"./server": {
|
|
99
|
+
"types": "./src/server/index.ts",
|
|
100
|
+
"default": "./dist/server.js"
|
|
101
|
+
}
|
|
95
102
|
}
|
|
96
103
|
}
|