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.
Files changed (4) hide show
  1. package/README.md +20 -20
  2. package/bin/ctk.js +14 -0
  3. package/dist/cli.js +1116 -17
  4. 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 workctl
9
- workctl dashboard
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 | 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 |
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 | 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` |
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(text) {
52
+ function spinner(text2) {
48
53
  return ora({
49
- text: c.muted(text),
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(text) {
74
- return `${c.accent("\u2192")} ${text}`;
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 text;
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
- text = chunks.join("\n");
336
+ text2 = chunks.join("\n");
332
337
  } else if (existsSync4(file)) {
333
- text = readFileSync4(file, "utf-8");
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: text }]
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(text.length / 4);
383
+ const rough = Math.floor(text2.length / 4);
379
384
  if (isJsonMode()) {
380
- output({ tokens: rough, method: "rough", chars: text.length });
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 tokenEvents = extractTokenEvents(events);
442
- return tokenEvents.reduce(
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 tokenEvents = extractTokenEvents(events);
455
- const last = tokenEvents.at(-1);
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.0.0";
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.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": "dist/cli.js"
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
  }