claudectx 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,3008 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // node_modules/tsup/assets/esm_shims.js
13
+ import path from "path";
14
+ import { fileURLToPath } from "url";
15
+ var init_esm_shims = __esm({
16
+ "node_modules/tsup/assets/esm_shims.js"() {
17
+ "use strict";
18
+ }
19
+ });
20
+
21
+ // src/analyzer/tokenizer.ts
22
+ import { get_encoding } from "js-tiktoken";
23
+ function getEncoder() {
24
+ if (!encoder) {
25
+ encoder = get_encoding("cl100k_base");
26
+ }
27
+ return encoder;
28
+ }
29
+ function countTokens(text) {
30
+ if (!text || text.length === 0) return 0;
31
+ try {
32
+ return getEncoder().encode(text).length;
33
+ } catch {
34
+ return Math.ceil(text.length / 4);
35
+ }
36
+ }
37
+ var encoder;
38
+ var init_tokenizer = __esm({
39
+ "src/analyzer/tokenizer.ts"() {
40
+ "use strict";
41
+ init_esm_shims();
42
+ encoder = null;
43
+ }
44
+ });
45
+
46
+ // src/shared/models.ts
47
+ function resolveModel(input) {
48
+ const lower = input.toLowerCase();
49
+ if (lower in MODEL_ALIASES) return MODEL_ALIASES[lower];
50
+ if (lower in MODEL_PRICING) return lower;
51
+ return DEFAULT_MODEL;
52
+ }
53
+ function calculateCost(tokens, model) {
54
+ if (tokens === 0) return 0;
55
+ return tokens / 1e6 * MODEL_PRICING[model].inputPerMillion;
56
+ }
57
+ var MODEL_PRICING, DEFAULT_MODEL, MODEL_ALIASES;
58
+ var init_models = __esm({
59
+ "src/shared/models.ts"() {
60
+ "use strict";
61
+ init_esm_shims();
62
+ MODEL_PRICING = {
63
+ "claude-haiku-4-5": {
64
+ inputPerMillion: 1,
65
+ outputPerMillion: 5,
66
+ cacheReadPerMillion: 0.1,
67
+ cacheWritePerMillion: 1.25,
68
+ contextWindow: 2e5
69
+ },
70
+ "claude-sonnet-4-6": {
71
+ inputPerMillion: 3,
72
+ outputPerMillion: 15,
73
+ cacheReadPerMillion: 0.3,
74
+ cacheWritePerMillion: 3.75,
75
+ contextWindow: 1e6
76
+ },
77
+ "claude-opus-4-6": {
78
+ inputPerMillion: 5,
79
+ outputPerMillion: 25,
80
+ cacheReadPerMillion: 0.5,
81
+ cacheWritePerMillion: 6.25,
82
+ contextWindow: 1e6
83
+ }
84
+ };
85
+ DEFAULT_MODEL = "claude-sonnet-4-6";
86
+ MODEL_ALIASES = {
87
+ haiku: "claude-haiku-4-5",
88
+ sonnet: "claude-sonnet-4-6",
89
+ opus: "claude-opus-4-6",
90
+ "claude-haiku": "claude-haiku-4-5",
91
+ "claude-sonnet": "claude-sonnet-4-6",
92
+ "claude-opus": "claude-opus-4-6"
93
+ };
94
+ }
95
+ });
96
+
97
+ // src/watcher/session-store.ts
98
+ var session_store_exports = {};
99
+ __export(session_store_exports, {
100
+ aggregateStats: () => aggregateStats,
101
+ appendFileRead: () => appendFileRead,
102
+ clearStore: () => clearStore,
103
+ getReadsFilePath: () => getReadsFilePath,
104
+ getStoreDir: () => getStoreDir,
105
+ readAllEvents: () => readAllEvents
106
+ });
107
+ import * as fs8 from "fs";
108
+ import * as os2 from "os";
109
+ import * as path9 from "path";
110
+ function getStoreDirPath() {
111
+ return path9.join(os2.homedir(), ".claudectx");
112
+ }
113
+ function getReadsFilePath_() {
114
+ return path9.join(getStoreDirPath(), "reads.jsonl");
115
+ }
116
+ function ensureStoreDir() {
117
+ const dir = getStoreDirPath();
118
+ if (!fs8.existsSync(dir)) {
119
+ fs8.mkdirSync(dir, { recursive: true });
120
+ }
121
+ }
122
+ function appendFileRead(filePath, sessionId) {
123
+ ensureStoreDir();
124
+ const event = {
125
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
126
+ filePath,
127
+ sessionId
128
+ };
129
+ fs8.appendFileSync(getReadsFilePath_(), JSON.stringify(event) + "\n", "utf-8");
130
+ }
131
+ function readAllEvents() {
132
+ const readsFile = getReadsFilePath_();
133
+ if (!fs8.existsSync(readsFile)) return [];
134
+ const lines = fs8.readFileSync(readsFile, "utf-8").trim().split("\n").filter(Boolean);
135
+ return lines.map((line) => {
136
+ try {
137
+ return JSON.parse(line);
138
+ } catch {
139
+ return null;
140
+ }
141
+ }).filter((e) => e !== null);
142
+ }
143
+ function aggregateStats(events) {
144
+ const map = /* @__PURE__ */ new Map();
145
+ for (const e of events) {
146
+ const existing = map.get(e.filePath);
147
+ if (existing) {
148
+ existing.readCount++;
149
+ existing.lastSeen = e.timestamp;
150
+ } else {
151
+ map.set(e.filePath, {
152
+ filePath: e.filePath,
153
+ readCount: 1,
154
+ firstSeen: e.timestamp,
155
+ lastSeen: e.timestamp
156
+ });
157
+ }
158
+ }
159
+ return [...map.values()].sort((a, b) => b.readCount - a.readCount);
160
+ }
161
+ function clearStore() {
162
+ const readsFile = getReadsFilePath_();
163
+ if (fs8.existsSync(readsFile)) {
164
+ fs8.writeFileSync(readsFile, "", "utf-8");
165
+ }
166
+ }
167
+ function getReadsFilePath() {
168
+ return getReadsFilePath_();
169
+ }
170
+ function getStoreDir() {
171
+ return getStoreDirPath();
172
+ }
173
+ var init_session_store = __esm({
174
+ "src/watcher/session-store.ts"() {
175
+ "use strict";
176
+ init_esm_shims();
177
+ }
178
+ });
179
+
180
+ // src/watcher/session-reader.ts
181
+ import * as fs9 from "fs";
182
+ import * as os3 from "os";
183
+ import * as path10 from "path";
184
+ function listSessionFiles() {
185
+ if (!fs9.existsSync(CLAUDE_PROJECTS_DIR)) return [];
186
+ const results = [];
187
+ try {
188
+ const projectDirs = fs9.readdirSync(CLAUDE_PROJECTS_DIR);
189
+ for (const projectDir of projectDirs) {
190
+ const projectPath = path10.join(CLAUDE_PROJECTS_DIR, projectDir);
191
+ try {
192
+ const stat = fs9.statSync(projectPath);
193
+ if (!stat.isDirectory()) continue;
194
+ const files = fs9.readdirSync(projectPath).filter((f) => f.endsWith(".jsonl"));
195
+ for (const file of files) {
196
+ const filePath = path10.join(projectPath, file);
197
+ try {
198
+ const fstat = fs9.statSync(filePath);
199
+ results.push({
200
+ filePath,
201
+ mtimeMs: fstat.mtimeMs,
202
+ sessionId: path10.basename(file, ".jsonl"),
203
+ projectDir
204
+ });
205
+ } catch {
206
+ }
207
+ }
208
+ } catch {
209
+ }
210
+ }
211
+ } catch {
212
+ return [];
213
+ }
214
+ return results.sort((a, b) => b.mtimeMs - a.mtimeMs);
215
+ }
216
+ function findSessionFile(sessionId) {
217
+ const files = listSessionFiles();
218
+ if (files.length === 0) return null;
219
+ if (sessionId) {
220
+ const match = files.find((f) => f.sessionId === sessionId);
221
+ return match?.filePath ?? null;
222
+ }
223
+ return files[0]?.filePath ?? null;
224
+ }
225
+ function readSessionUsage(sessionFilePath) {
226
+ const result = {
227
+ inputTokens: 0,
228
+ outputTokens: 0,
229
+ cacheCreationTokens: 0,
230
+ cacheReadTokens: 0,
231
+ requestCount: 0
232
+ };
233
+ if (!fs9.existsSync(sessionFilePath)) return result;
234
+ let content;
235
+ try {
236
+ content = fs9.readFileSync(sessionFilePath, "utf-8");
237
+ } catch {
238
+ return result;
239
+ }
240
+ const lines = content.trim().split("\n").filter(Boolean);
241
+ for (const line of lines) {
242
+ try {
243
+ const entry = JSON.parse(line);
244
+ const usage = entry.usage ?? entry.message?.usage;
245
+ if (!usage) continue;
246
+ const isAssistant = entry.type === "assistant" || entry.message?.role === "assistant";
247
+ if (isAssistant) {
248
+ result.inputTokens += usage.input_tokens ?? 0;
249
+ result.outputTokens += usage.output_tokens ?? 0;
250
+ result.cacheCreationTokens += usage.cache_creation_input_tokens ?? 0;
251
+ result.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
252
+ result.requestCount++;
253
+ }
254
+ } catch {
255
+ }
256
+ }
257
+ return result;
258
+ }
259
+ var CLAUDE_PROJECTS_DIR;
260
+ var init_session_reader = __esm({
261
+ "src/watcher/session-reader.ts"() {
262
+ "use strict";
263
+ init_esm_shims();
264
+ CLAUDE_PROJECTS_DIR = path10.join(os3.homedir(), ".claude", "projects");
265
+ }
266
+ });
267
+
268
+ // src/components/Dashboard.tsx
269
+ var Dashboard_exports = {};
270
+ __export(Dashboard_exports, {
271
+ Dashboard: () => Dashboard
272
+ });
273
+ import { useState, useEffect, useCallback } from "react";
274
+ import { Box, Text, useApp, useInput } from "ink";
275
+ import * as fs10 from "fs";
276
+ import * as path11 from "path";
277
+ import { jsx, jsxs } from "react/jsx-runtime";
278
+ function fmtNum(n) {
279
+ return n.toLocaleString();
280
+ }
281
+ function fmtCost(tokens, model) {
282
+ const p = MODEL_PRICING[model];
283
+ const cost = tokens / 1e6 * p.inputPerMillion;
284
+ return `$${cost.toFixed(4)}`;
285
+ }
286
+ function shortPath(filePath) {
287
+ const parts = filePath.split(path11.sep);
288
+ if (parts.length <= 3) return filePath;
289
+ return "\u2026/" + parts.slice(-3).join("/");
290
+ }
291
+ function padEnd(str, len) {
292
+ return str.length >= len ? str.slice(0, len) : str + " ".repeat(len - str.length);
293
+ }
294
+ function padStart(str, len) {
295
+ return str.length >= len ? str.slice(0, len) : " ".repeat(len - str.length) + str;
296
+ }
297
+ function Spinner({ tick }) {
298
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
299
+ return /* @__PURE__ */ jsx(Text, { color: "cyan", children: frames[tick % frames.length] });
300
+ }
301
+ function SectionTitle({ children }) {
302
+ return /* @__PURE__ */ jsx(Box, { marginBottom: 0, children: /* @__PURE__ */ jsx(Text, { bold: true, underline: true, color: "white", children }) });
303
+ }
304
+ function UsagePanel({
305
+ usage,
306
+ model
307
+ }) {
308
+ const totalBillable = usage.inputTokens + usage.outputTokens;
309
+ const cacheHitPct = usage.inputTokens > 0 ? (usage.cacheReadTokens / usage.inputTokens * 100).toFixed(1) : "0.0";
310
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
311
+ /* @__PURE__ */ jsx(SectionTitle, { children: "Token Usage" }),
312
+ /* @__PURE__ */ jsxs(Box, { children: [
313
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " Input: " }),
314
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: fmtNum(usage.inputTokens) }),
315
+ usage.cacheReadTokens > 0 && /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` (${fmtNum(usage.cacheReadTokens)} from cache, ${cacheHitPct}% hit)` })
316
+ ] }),
317
+ /* @__PURE__ */ jsxs(Box, { children: [
318
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " Output: " }),
319
+ /* @__PURE__ */ jsx(Text, { color: "green", children: fmtNum(usage.outputTokens) })
320
+ ] }),
321
+ /* @__PURE__ */ jsxs(Box, { children: [
322
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " Cache writes: " }),
323
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: fmtNum(usage.cacheCreationTokens) })
324
+ ] }),
325
+ /* @__PURE__ */ jsxs(Box, { children: [
326
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " Requests: " }),
327
+ /* @__PURE__ */ jsx(Text, { children: usage.requestCount })
328
+ ] }),
329
+ /* @__PURE__ */ jsxs(Box, { children: [
330
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " Estimated cost: " }),
331
+ /* @__PURE__ */ jsx(Text, { color: "magenta", children: fmtCost(totalBillable, model) })
332
+ ] })
333
+ ] });
334
+ }
335
+ function FileTable({ stats }) {
336
+ const COL_NUM = 4;
337
+ const COL_READS = 6;
338
+ const COL_FILE = 55;
339
+ if (stats.length === 0) {
340
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
341
+ /* @__PURE__ */ jsx(SectionTitle, { children: "Files Read" }),
342
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
343
+ " No file reads tracked yet.\n Install hooks first: ",
344
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "claudectx optimize --hooks" })
345
+ ] })
346
+ ] });
347
+ }
348
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
349
+ /* @__PURE__ */ jsx(SectionTitle, { children: `Files Read (${stats.length} unique)` }),
350
+ /* @__PURE__ */ jsxs(Box, { children: [
351
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: padStart("#", COL_NUM) + " " }),
352
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: padEnd("File", COL_FILE) + " " }),
353
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: padStart("Reads", COL_READS) })
354
+ ] }),
355
+ stats.slice(0, 18).map((s, i) => /* @__PURE__ */ jsxs(Box, { children: [
356
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: padStart(String(i + 1), COL_NUM) + " " }),
357
+ /* @__PURE__ */ jsx(Text, { children: padEnd(shortPath(s.filePath), COL_FILE) + " " }),
358
+ /* @__PURE__ */ jsx(Text, { color: s.readCount >= 3 ? "yellow" : "white", children: padStart(String(s.readCount), COL_READS) })
359
+ ] }, s.filePath)),
360
+ stats.length > 18 && /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` \u2026 and ${stats.length - 18} more` })
361
+ ] });
362
+ }
363
+ function Dashboard({
364
+ model = "claude-sonnet-4-6",
365
+ sessionId
366
+ }) {
367
+ const { exit } = useApp();
368
+ const [state, setState] = useState({
369
+ fileStats: [],
370
+ usage: {
371
+ inputTokens: 0,
372
+ outputTokens: 0,
373
+ cacheCreationTokens: 0,
374
+ cacheReadTokens: 0,
375
+ requestCount: 0
376
+ },
377
+ sessionFile: null,
378
+ lastUpdated: /* @__PURE__ */ new Date(),
379
+ tickCount: 0
380
+ });
381
+ const refresh = useCallback(() => {
382
+ const events = readAllEvents();
383
+ const fileStats2 = aggregateStats(events);
384
+ const sessionFile2 = sessionId ? findSessionFile(sessionId) : findSessionFile();
385
+ const usage2 = sessionFile2 ? readSessionUsage(sessionFile2) : {
386
+ inputTokens: 0,
387
+ outputTokens: 0,
388
+ cacheCreationTokens: 0,
389
+ cacheReadTokens: 0,
390
+ requestCount: 0
391
+ };
392
+ setState((prev) => ({
393
+ fileStats: fileStats2,
394
+ usage: usage2,
395
+ sessionFile: sessionFile2,
396
+ lastUpdated: /* @__PURE__ */ new Date(),
397
+ tickCount: prev.tickCount + 1
398
+ }));
399
+ }, [sessionId]);
400
+ useEffect(() => {
401
+ refresh();
402
+ const interval = setInterval(refresh, 2e3);
403
+ const readsFile = getReadsFilePath();
404
+ let watcher = null;
405
+ const tryWatch = () => {
406
+ if (fs10.existsSync(readsFile)) {
407
+ try {
408
+ watcher = fs10.watch(readsFile, () => refresh());
409
+ } catch {
410
+ }
411
+ }
412
+ };
413
+ tryWatch();
414
+ const watchRetry = setTimeout(tryWatch, 3e3);
415
+ return () => {
416
+ clearInterval(interval);
417
+ clearTimeout(watchRetry);
418
+ watcher?.close();
419
+ };
420
+ }, [refresh]);
421
+ useEffect(() => {
422
+ const ticker = setInterval(() => {
423
+ setState((prev) => ({ ...prev, tickCount: prev.tickCount + 1 }));
424
+ }, 100);
425
+ return () => clearInterval(ticker);
426
+ }, []);
427
+ useInput((input, key) => {
428
+ if (input === "q" || input === "Q" || key.escape) {
429
+ exit();
430
+ }
431
+ if (input === "r" || input === "R") {
432
+ refresh();
433
+ }
434
+ });
435
+ const { fileStats, usage, sessionFile, lastUpdated, tickCount } = state;
436
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 0, children: [
437
+ /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
438
+ /* @__PURE__ */ jsx(Spinner, { tick: tickCount }),
439
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: " claudectx watch" }),
440
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
441
+ " \u2014 Live Session Monitor \u2014 ",
442
+ lastUpdated.toLocaleTimeString()
443
+ ] }),
444
+ sessionFile && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
445
+ " \u2014 ",
446
+ path11.basename(sessionFile, ".jsonl").slice(0, 8),
447
+ "\u2026"
448
+ ] }),
449
+ !sessionFile && /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \u2014 no session file found" })
450
+ ] }),
451
+ /* @__PURE__ */ jsx(UsagePanel, { usage, model }),
452
+ /* @__PURE__ */ jsx(FileTable, { stats: fileStats }),
453
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
454
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press " }),
455
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "q" }),
456
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " to quit \u2022 " }),
457
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "r" }),
458
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " to refresh \u2022 Polls every 2s" })
459
+ ] })
460
+ ] });
461
+ }
462
+ var init_Dashboard = __esm({
463
+ "src/components/Dashboard.tsx"() {
464
+ "use strict";
465
+ init_esm_shims();
466
+ init_session_store();
467
+ init_session_reader();
468
+ init_models();
469
+ }
470
+ });
471
+
472
+ // src/mcp/smart-reader.ts
473
+ import * as fs12 from "fs";
474
+ import * as path14 from "path";
475
+ function detectLanguage(filePath) {
476
+ const ext = path14.extname(filePath).toLowerCase();
477
+ switch (ext) {
478
+ case ".ts":
479
+ case ".tsx":
480
+ return "typescript";
481
+ case ".js":
482
+ case ".jsx":
483
+ case ".mjs":
484
+ case ".cjs":
485
+ return "javascript";
486
+ case ".py":
487
+ return "python";
488
+ default:
489
+ return "other";
490
+ }
491
+ }
492
+ function findSymbol(filePath, symbolName) {
493
+ if (!fs12.existsSync(filePath)) return null;
494
+ const content = fs12.readFileSync(filePath, "utf-8");
495
+ const lines = content.split("\n");
496
+ const lang = detectLanguage(filePath);
497
+ const patterns = lang === "python" ? PYTHON_PATTERNS : TS_JS_PATTERNS;
498
+ for (let i = 0; i < lines.length; i++) {
499
+ const line = lines[i].trim();
500
+ for (const { pattern, type } of patterns) {
501
+ const match = line.match(pattern);
502
+ if (!match) continue;
503
+ const capturedName = match[1];
504
+ if (!capturedName || capturedName !== symbolName) continue;
505
+ const startLine = i + 1;
506
+ const endLine = lang === "python" ? findPythonBlockEnd(lines, i) : findBraceBlockEnd(lines, i);
507
+ const extracted = lines.slice(i, endLine).join("\n");
508
+ return {
509
+ name: capturedName,
510
+ type,
511
+ filePath,
512
+ startLine,
513
+ endLine,
514
+ content: extracted,
515
+ tokenCount: countTokens(extracted),
516
+ language: lang
517
+ };
518
+ }
519
+ }
520
+ return null;
521
+ }
522
+ function findBraceBlockEnd(lines, startIdx) {
523
+ let depth = 0;
524
+ let foundOpenBrace = false;
525
+ for (let i = startIdx; i < lines.length; i++) {
526
+ const line = lines[i];
527
+ for (const ch of line) {
528
+ if (ch === "{") {
529
+ depth++;
530
+ foundOpenBrace = true;
531
+ } else if (ch === "}") {
532
+ depth--;
533
+ if (foundOpenBrace && depth === 0) {
534
+ return i + 1;
535
+ }
536
+ }
537
+ }
538
+ }
539
+ return Math.min(startIdx + 60, lines.length);
540
+ }
541
+ function findPythonBlockEnd(lines, startIdx) {
542
+ const baseLine = lines[startIdx];
543
+ const baseIndent = baseLine.length - baseLine.trimStart().length;
544
+ for (let i = startIdx + 1; i < lines.length; i++) {
545
+ const line = lines[i];
546
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
547
+ const indent = line.length - line.trimStart().length;
548
+ if (indent <= baseIndent) {
549
+ return i;
550
+ }
551
+ }
552
+ return lines.length;
553
+ }
554
+ function extractLineRange(filePath, startLine, endLine, contextLines = 0) {
555
+ if (!fs12.existsSync(filePath)) return null;
556
+ const allLines = fs12.readFileSync(filePath, "utf-8").split("\n");
557
+ const totalLines = allLines.length;
558
+ const from = Math.max(0, startLine - 1 - contextLines);
559
+ const to = Math.min(totalLines, endLine + contextLines);
560
+ const extracted = allLines.slice(from, to).join("\n");
561
+ return {
562
+ filePath,
563
+ startLine: from + 1,
564
+ endLine: to,
565
+ content: extracted,
566
+ tokenCount: countTokens(extracted),
567
+ totalLines
568
+ };
569
+ }
570
+ function smartRead(filePath, symbol, startLine, endLine, contextLines = 3) {
571
+ if (!fs12.existsSync(filePath)) {
572
+ throw new Error(`File not found: ${filePath}`);
573
+ }
574
+ if (symbol) {
575
+ const extracted = findSymbol(filePath, symbol);
576
+ if (extracted) {
577
+ return {
578
+ content: extracted.content,
579
+ tokenCount: extracted.tokenCount,
580
+ filePath,
581
+ startLine: extracted.startLine,
582
+ endLine: extracted.endLine,
583
+ totalLines: fs12.readFileSync(filePath, "utf-8").split("\n").length,
584
+ truncated: false,
585
+ symbolName: symbol
586
+ };
587
+ }
588
+ }
589
+ if (startLine !== void 0 && endLine !== void 0) {
590
+ const result = extractLineRange(filePath, startLine, endLine, contextLines);
591
+ if (result) {
592
+ return { ...result, truncated: false };
593
+ }
594
+ }
595
+ const fullContent = fs12.readFileSync(filePath, "utf-8");
596
+ const allLines = fullContent.split("\n");
597
+ const totalLines = allLines.length;
598
+ const fullTokens = countTokens(fullContent);
599
+ if (fullTokens <= MAX_FULL_FILE_TOKENS) {
600
+ return {
601
+ content: fullContent,
602
+ tokenCount: fullTokens,
603
+ filePath,
604
+ startLine: 1,
605
+ endLine: totalLines,
606
+ totalLines,
607
+ truncated: false
608
+ };
609
+ }
610
+ let accumulated = "";
611
+ let lastLine = 0;
612
+ for (let i = 0; i < allLines.length; i++) {
613
+ const next = accumulated + allLines[i] + "\n";
614
+ if (countTokens(next) > MAX_FULL_FILE_TOKENS) break;
615
+ accumulated = next;
616
+ lastLine = i + 1;
617
+ }
618
+ return {
619
+ content: accumulated + `
620
+
621
+ // ... file truncated at ${lastLine}/${totalLines} lines (token budget).
622
+ // Use smart_read with a symbol name or line range to read more.`,
623
+ tokenCount: countTokens(accumulated),
624
+ filePath,
625
+ startLine: 1,
626
+ endLine: lastLine,
627
+ totalLines,
628
+ truncated: true
629
+ };
630
+ }
631
+ var TS_JS_PATTERNS, PYTHON_PATTERNS, MAX_FULL_FILE_TOKENS;
632
+ var init_smart_reader = __esm({
633
+ "src/mcp/smart-reader.ts"() {
634
+ "use strict";
635
+ init_esm_shims();
636
+ init_tokenizer();
637
+ TS_JS_PATTERNS = [
638
+ // export async function name / export function name / function name
639
+ { pattern: /^(?:export\s+)?(?:async\s+)?function\s+(\w+)/, type: "function" },
640
+ // export default function name
641
+ { pattern: /^export\s+default\s+(?:async\s+)?function\s+(\w+)?/, type: "function" },
642
+ // const/let/var name = (params) => / async (params) =>
643
+ { pattern: /^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:[(][^)]*[)]|\w+)\s*=>/, type: "function" },
644
+ // const/let name = function
645
+ { pattern: /^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?function/, type: "function" },
646
+ // export abstract class / export class / class
647
+ { pattern: /^(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/, type: "class" },
648
+ // export interface / interface
649
+ { pattern: /^(?:export\s+)?interface\s+(\w+)/, type: "interface" },
650
+ // export type Name = / type Name =
651
+ { pattern: /^(?:export\s+)?type\s+(\w+)\s*(?:<[^>]*>)?\s*=/, type: "type" },
652
+ // export enum / enum
653
+ { pattern: /^(?:export\s+)?(?:const\s+)?enum\s+(\w+)/, type: "type" },
654
+ // export const NAME (capital-snake — treat as variable)
655
+ { pattern: /^(?:export\s+)?const\s+([A-Z_][A-Z0-9_]+)\s*=/, type: "variable" },
656
+ // export const name (lowercase)
657
+ { pattern: /^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*(?::\s*\S+)?\s*=/, type: "variable" }
658
+ ];
659
+ PYTHON_PATTERNS = [
660
+ { pattern: /^(?:async\s+)?def\s+(\w+)\s*\(/, type: "function" },
661
+ { pattern: /^class\s+(\w+)(?:\s*[(:]|$)/, type: "class" },
662
+ { pattern: /^([A-Z_][A-Z0-9_]+)\s*=/, type: "variable" }
663
+ ];
664
+ MAX_FULL_FILE_TOKENS = 8e3;
665
+ }
666
+ });
667
+
668
+ // src/mcp/symbol-index.ts
669
+ import * as fs13 from "fs";
670
+ import * as path15 from "path";
671
+ import { glob } from "glob";
672
+ function extractSymbolsFromFile(filePath) {
673
+ const lang = detectLanguage(filePath);
674
+ if (lang === "other") return [];
675
+ let content;
676
+ try {
677
+ content = fs13.readFileSync(filePath, "utf-8");
678
+ } catch {
679
+ return [];
680
+ }
681
+ const lines = content.split("\n");
682
+ const extractors = lang === "python" ? PYTHON_EXTRACTORS : TS_JS_EXTRACTORS;
683
+ const results = [];
684
+ const seenNames = /* @__PURE__ */ new Set();
685
+ for (let i = 0; i < lines.length; i++) {
686
+ const trimmed = lines[i].trim();
687
+ if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("#")) continue;
688
+ for (const { pattern, type } of extractors) {
689
+ const match = trimmed.match(pattern);
690
+ if (!match?.[1]) continue;
691
+ const name = match[1];
692
+ if (seenNames.has(name)) continue;
693
+ seenNames.add(name);
694
+ results.push({
695
+ name,
696
+ type,
697
+ filePath,
698
+ lineStart: i + 1,
699
+ signature: lines[i].trimEnd()
700
+ });
701
+ break;
702
+ }
703
+ }
704
+ return results;
705
+ }
706
+ var TS_JS_EXTRACTORS, PYTHON_EXTRACTORS, SOURCE_GLOBS, IGNORE_DIRS, SymbolIndex, globalIndex;
707
+ var init_symbol_index = __esm({
708
+ "src/mcp/symbol-index.ts"() {
709
+ "use strict";
710
+ init_esm_shims();
711
+ init_smart_reader();
712
+ TS_JS_EXTRACTORS = [
713
+ { pattern: /^(?:export\s+)?(?:async\s+)?function\s+(\w+)/, type: "function" },
714
+ { pattern: /^export\s+default\s+(?:async\s+)?function\s+(\w+)/, type: "function" },
715
+ { pattern: /^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:[(][^)]*[)]|\w+)\s*=>/, type: "function" },
716
+ { pattern: /^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?function/, type: "function" },
717
+ { pattern: /^(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/, type: "class" },
718
+ { pattern: /^(?:export\s+)?interface\s+(\w+)/, type: "interface" },
719
+ { pattern: /^(?:export\s+)?type\s+(\w+)\s*(?:<[^>]*>)?\s*=/, type: "type" },
720
+ { pattern: /^(?:export\s+)?(?:const\s+)?enum\s+(\w+)/, type: "type" },
721
+ { pattern: /^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*(?::\s*\S+)?\s*=/, type: "variable" }
722
+ ];
723
+ PYTHON_EXTRACTORS = [
724
+ { pattern: /^(?:async\s+)?def\s+(\w+)\s*\(/, type: "function" },
725
+ { pattern: /^class\s+(\w+)(?:\s*[(:]|$)/, type: "class" },
726
+ { pattern: /^([A-Z_][A-Z0-9_]+)\s*=/, type: "variable" }
727
+ ];
728
+ SOURCE_GLOBS = [
729
+ "**/*.ts",
730
+ "**/*.tsx",
731
+ "**/*.js",
732
+ "**/*.jsx",
733
+ "**/*.mjs",
734
+ "**/*.py"
735
+ ];
736
+ IGNORE_DIRS = [
737
+ "node_modules/**",
738
+ "dist/**",
739
+ "build/**",
740
+ ".git/**",
741
+ "__pycache__/**",
742
+ "*.min.js",
743
+ "**/*.d.ts"
744
+ ];
745
+ SymbolIndex = class {
746
+ entries = [];
747
+ builtFor = null;
748
+ buildInProgress = false;
749
+ /** Build the index for a project root. Subsequent calls are no-ops if root matches. */
750
+ async build(projectRoot) {
751
+ if (this.builtFor === projectRoot) {
752
+ return { fileCount: 0, symbolCount: this.entries.length };
753
+ }
754
+ if (this.buildInProgress) {
755
+ await new Promise((r) => setTimeout(r, 200));
756
+ return { fileCount: 0, symbolCount: this.entries.length };
757
+ }
758
+ this.buildInProgress = true;
759
+ this.entries = [];
760
+ let files = [];
761
+ try {
762
+ files = await glob(SOURCE_GLOBS.map((g) => path15.join(projectRoot, g)), {
763
+ ignore: IGNORE_DIRS.map((g) => path15.join(projectRoot, g)),
764
+ absolute: true
765
+ });
766
+ } catch {
767
+ }
768
+ for (const file of files) {
769
+ const symbols = extractSymbolsFromFile(file);
770
+ this.entries.push(...symbols);
771
+ }
772
+ this.builtFor = projectRoot;
773
+ this.buildInProgress = false;
774
+ return { fileCount: files.length, symbolCount: this.entries.length };
775
+ }
776
+ /** Rebuild the index (e.g. after file changes). */
777
+ async rebuild(projectRoot) {
778
+ this.builtFor = null;
779
+ return this.build(projectRoot);
780
+ }
781
+ /**
782
+ * Search for symbols matching the query.
783
+ *
784
+ * @param query - Partial or full symbol name (case-insensitive substring match)
785
+ * @param type - Optional filter by symbol type
786
+ * @param pathFilter - Optional substring filter on the file path
787
+ * @param limit - Max results to return (default 20)
788
+ */
789
+ search(query, type, pathFilter, limit = 20) {
790
+ const q = query.toLowerCase();
791
+ return this.entries.filter((e) => {
792
+ if (!e.name.toLowerCase().includes(q)) return false;
793
+ if (type && type !== "all" && e.type !== type) return false;
794
+ if (pathFilter && !e.filePath.includes(pathFilter)) return false;
795
+ return true;
796
+ }).slice(0, limit);
797
+ }
798
+ /** Total symbol count. */
799
+ get size() {
800
+ return this.entries.length;
801
+ }
802
+ /** Whether the index has been built. */
803
+ get isReady() {
804
+ return this.builtFor !== null;
805
+ }
806
+ };
807
+ globalIndex = new SymbolIndex();
808
+ }
809
+ });
810
+
811
+ // src/mcp/server.ts
812
+ var server_exports = {};
813
+ __export(server_exports, {
814
+ startMcpServer: () => startMcpServer
815
+ });
816
+ import * as path16 from "path";
817
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
818
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
819
+ import {
820
+ CallToolRequestSchema,
821
+ ListToolsRequestSchema
822
+ } from "@modelcontextprotocol/sdk/types.js";
823
+ function handleSmartRead(args) {
824
+ const filePath = path16.resolve(args.file);
825
+ const result = smartRead(
826
+ filePath,
827
+ args.symbol,
828
+ args.start_line,
829
+ args.end_line,
830
+ args.context_lines ?? 3
831
+ );
832
+ const header = [
833
+ `// File: ${result.filePath}`,
834
+ result.symbolName ? `// Symbol: ${result.symbolName} (lines ${result.startLine}\u2013${result.endLine})` : `// Lines: ${result.startLine}\u2013${result.endLine} of ${result.totalLines}`,
835
+ `// Tokens: ${result.tokenCount}${result.truncated ? " (truncated \u2014 file is large)" : ""}`,
836
+ ""
837
+ ].join("\n");
838
+ return header + result.content;
839
+ }
840
+ async function handleSearchSymbols(args) {
841
+ if (!globalIndex.isReady) {
842
+ return `Index not built yet. Call index_project first.
843
+ Example: index_project({ "project_root": "${process.cwd()}" })`;
844
+ }
845
+ const results = globalIndex.search(
846
+ args.query,
847
+ args.type ?? "all",
848
+ args.path_filter,
849
+ args.limit ?? 20
850
+ );
851
+ if (results.length === 0) {
852
+ return `No symbols found matching "${args.query}".`;
853
+ }
854
+ const lines = [
855
+ `Found ${results.length} symbol(s) matching "${args.query}":`,
856
+ ""
857
+ ];
858
+ for (let i = 0; i < results.length; i++) {
859
+ const r = results[i];
860
+ const rel = path16.relative(process.cwd(), r.filePath);
861
+ lines.push(`${i + 1}. [${r.type}] ${r.name}`);
862
+ lines.push(` ${rel}:${r.lineStart}`);
863
+ lines.push(` ${r.signature.trim()}`);
864
+ lines.push("");
865
+ }
866
+ lines.push(
867
+ `Tip: Use smart_read({ "file": "<path>", "symbol": "<name>" }) to read a specific symbol.`
868
+ );
869
+ return lines.join("\n");
870
+ }
871
+ async function handleIndexProject(args) {
872
+ const projectRoot = args.project_root ? path16.resolve(args.project_root) : process.cwd();
873
+ const fn = args.rebuild ? () => globalIndex.rebuild(projectRoot) : () => globalIndex.build(projectRoot);
874
+ const { fileCount, symbolCount } = await fn();
875
+ if (fileCount === 0 && globalIndex.isReady) {
876
+ return `Index already built: ${globalIndex.size} symbols. Pass rebuild: true to force re-index.`;
877
+ }
878
+ return `Index built for: ${projectRoot}
879
+ Files scanned: ${fileCount}
880
+ Symbols indexed: ${symbolCount}
881
+
882
+ Use search_symbols({ "query": "<name>" }) to find symbols.`;
883
+ }
884
+ async function startMcpServer() {
885
+ const server = new Server(
886
+ { name: "claudectx", version: "0.1.0" },
887
+ { capabilities: { tools: {} } }
888
+ );
889
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
890
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
891
+ const { name, arguments: args } = request.params;
892
+ try {
893
+ let text;
894
+ switch (name) {
895
+ case "smart_read":
896
+ text = handleSmartRead(args);
897
+ break;
898
+ case "search_symbols":
899
+ text = await handleSearchSymbols(args);
900
+ break;
901
+ case "index_project":
902
+ text = await handleIndexProject(args);
903
+ break;
904
+ default:
905
+ throw new Error(`Unknown tool: ${name}`);
906
+ }
907
+ return { content: [{ type: "text", text }] };
908
+ } catch (err) {
909
+ const message = err instanceof Error ? err.message : String(err);
910
+ return {
911
+ content: [{ type: "text", text: `Error: ${message}` }],
912
+ isError: true
913
+ };
914
+ }
915
+ });
916
+ const transport = new StdioServerTransport();
917
+ await server.connect(transport);
918
+ process.stderr.write("[claudectx mcp] Server started (stdio)\n");
919
+ }
920
+ var TOOLS;
921
+ var init_server = __esm({
922
+ "src/mcp/server.ts"() {
923
+ "use strict";
924
+ init_esm_shims();
925
+ init_smart_reader();
926
+ init_symbol_index();
927
+ TOOLS = [
928
+ {
929
+ name: "smart_read",
930
+ description: "Read a specific symbol (function, class, interface) or line range from a file instead of the whole file. Saves 60-90% of tokens on large files. Falls back to full file if symbol is not found (capped at 8K tokens).",
931
+ inputSchema: {
932
+ type: "object",
933
+ properties: {
934
+ file: {
935
+ type: "string",
936
+ description: "Absolute or relative path to the file to read."
937
+ },
938
+ symbol: {
939
+ type: "string",
940
+ description: "Name of the function, class, interface, or type to extract. If provided, only that symbol block is returned."
941
+ },
942
+ start_line: {
943
+ type: "number",
944
+ description: "Start line (1-based, inclusive). Use with end_line."
945
+ },
946
+ end_line: {
947
+ type: "number",
948
+ description: "End line (1-based, inclusive). Use with start_line."
949
+ },
950
+ context_lines: {
951
+ type: "number",
952
+ description: "Extra lines of context above/below line range (default 3)."
953
+ }
954
+ },
955
+ required: ["file"]
956
+ }
957
+ },
958
+ {
959
+ name: "search_symbols",
960
+ description: "Search for functions, classes, variables, and interfaces by name across the indexed codebase. Returns file paths and line numbers. Run index_project first to populate the index.",
961
+ inputSchema: {
962
+ type: "object",
963
+ properties: {
964
+ query: {
965
+ type: "string",
966
+ description: "Symbol name to search for (substring match, case-insensitive)."
967
+ },
968
+ type: {
969
+ type: "string",
970
+ enum: ["function", "class", "interface", "type", "variable", "all"],
971
+ description: "Filter by symbol type (default: all)."
972
+ },
973
+ path_filter: {
974
+ type: "string",
975
+ description: "Only include results from files whose path contains this string."
976
+ },
977
+ limit: {
978
+ type: "number",
979
+ description: "Maximum number of results (default 20)."
980
+ }
981
+ },
982
+ required: ["query"]
983
+ }
984
+ },
985
+ {
986
+ name: "index_project",
987
+ description: "Build or rebuild the symbol index for a project directory. Required before using search_symbols. Takes a few seconds for large projects.",
988
+ inputSchema: {
989
+ type: "object",
990
+ properties: {
991
+ project_root: {
992
+ type: "string",
993
+ description: "Absolute path to the project root (default: cwd)."
994
+ },
995
+ rebuild: {
996
+ type: "boolean",
997
+ description: "Force a full rebuild even if the index is already built."
998
+ }
999
+ },
1000
+ required: []
1001
+ }
1002
+ }
1003
+ ];
1004
+ }
1005
+ });
1006
+
1007
+ // src/index.ts
1008
+ init_esm_shims();
1009
+ import { Command } from "commander";
1010
+
1011
+ // src/commands/analyze.ts
1012
+ init_esm_shims();
1013
+ import path4 from "path";
1014
+ import chalk from "chalk";
1015
+ import boxen from "boxen";
1016
+ import Table from "cli-table3";
1017
+
1018
+ // src/analyzer/index.ts
1019
+ init_esm_shims();
1020
+
1021
+ // src/analyzer/context-parser.ts
1022
+ init_esm_shims();
1023
+ import fs from "fs";
1024
+ import path2 from "path";
1025
+ import os from "os";
1026
+ function findProjectRoot(startDir = process.cwd()) {
1027
+ let current = startDir;
1028
+ while (true) {
1029
+ if (fs.existsSync(path2.join(current, "CLAUDE.md")) || fs.existsSync(path2.join(current, ".claude"))) {
1030
+ return current;
1031
+ }
1032
+ const parent = path2.dirname(current);
1033
+ if (parent === current) return null;
1034
+ current = parent;
1035
+ }
1036
+ }
1037
+ function extractReferences(content) {
1038
+ const refs = [];
1039
+ const lines = content.split("\n");
1040
+ for (const line of lines) {
1041
+ const match = line.match(/^@(.+)$/);
1042
+ if (match) refs.push(match[1].trim());
1043
+ }
1044
+ return refs;
1045
+ }
1046
+ function countMcpTools(settingsPath) {
1047
+ try {
1048
+ const raw = fs.readFileSync(settingsPath, "utf-8");
1049
+ const settings = JSON.parse(raw);
1050
+ const servers = settings?.mcpServers ?? {};
1051
+ return Object.keys(servers).length * 3;
1052
+ } catch {
1053
+ return 0;
1054
+ }
1055
+ }
1056
+ function readFileSafe(filePath) {
1057
+ try {
1058
+ return fs.readFileSync(filePath, "utf-8");
1059
+ } catch {
1060
+ return null;
1061
+ }
1062
+ }
1063
+ function parseContext(projectPath) {
1064
+ const result = {
1065
+ referencedFiles: [],
1066
+ mcpToolCount: 0,
1067
+ projectRoot: findProjectRoot(projectPath)
1068
+ };
1069
+ const root = result.projectRoot ?? projectPath;
1070
+ const projectClaudeMdPath = path2.join(root, "CLAUDE.md");
1071
+ const projectClaudeMdContent = readFileSafe(projectClaudeMdPath);
1072
+ if (projectClaudeMdContent !== null) {
1073
+ result.projectClaudeMd = { filePath: projectClaudeMdPath, content: projectClaudeMdContent };
1074
+ }
1075
+ const userClaudeMdPath = path2.join(os.homedir(), ".claude", "CLAUDE.md");
1076
+ const userClaudeMdContent = readFileSafe(userClaudeMdPath);
1077
+ if (userClaudeMdContent !== null) {
1078
+ result.userClaudeMd = { filePath: userClaudeMdPath, content: userClaudeMdContent };
1079
+ }
1080
+ const memoryPath = path2.join(root, ".claude", "MEMORY.md");
1081
+ const memoryContent = readFileSafe(memoryPath);
1082
+ if (memoryContent !== null) {
1083
+ result.memoryMd = { filePath: memoryPath, content: memoryContent };
1084
+ }
1085
+ const settingsPath = path2.join(root, ".claude", "settings.json");
1086
+ result.mcpToolCount = countMcpTools(settingsPath);
1087
+ const allClaudeMdContent = [
1088
+ projectClaudeMdContent,
1089
+ userClaudeMdContent
1090
+ ].filter(Boolean).join("\n");
1091
+ const refs = extractReferences(allClaudeMdContent);
1092
+ for (const ref of refs) {
1093
+ const refPath = path2.isAbsolute(ref) ? ref : path2.join(root, ref);
1094
+ const refContent = readFileSafe(refPath);
1095
+ if (refContent !== null) {
1096
+ result.referencedFiles.push({
1097
+ filePath: refPath,
1098
+ content: refContent,
1099
+ referencedAs: ref
1100
+ });
1101
+ }
1102
+ }
1103
+ return result;
1104
+ }
1105
+
1106
+ // src/analyzer/index.ts
1107
+ init_tokenizer();
1108
+
1109
+ // src/analyzer/waste-detector.ts
1110
+ init_esm_shims();
1111
+ init_tokenizer();
1112
+ import fs2 from "fs";
1113
+ import path3 from "path";
1114
+
1115
+ // src/shared/constants.ts
1116
+ init_esm_shims();
1117
+ var BUILTIN_OVERHEAD = {
1118
+ /** Claude Code's own system prompt */
1119
+ SYSTEM_PROMPT: 4200,
1120
+ /** Built-in tool schemas (Read, Edit, Bash, Glob, Grep, etc.) */
1121
+ TOOL_DEFINITIONS: 2100,
1122
+ /** Approximate tokens per registered MCP tool schema */
1123
+ MCP_PER_TOOL: 180
1124
+ };
1125
+ var WASTE_THRESHOLDS = {
1126
+ /** CLAUDE.md token count above this triggers OVERSIZED_CLAUDEMD */
1127
+ MAX_CLAUDEMD_TOKENS: 2e3,
1128
+ /** MEMORY.md token count above this triggers OVERSIZED_MEMORY */
1129
+ MAX_MEMORY_TOKENS: 3e3,
1130
+ /** Referenced file token count above this triggers LARGE_REFERENCE_FILE */
1131
+ MAX_REFERENCE_FILE_TOKENS: 5e3,
1132
+ /** Number of @referenced files above this triggers TOO_MANY_REFERENCES */
1133
+ MAX_REFERENCE_COUNT: 5
1134
+ };
1135
+ var SESSION_DEFAULTS = {
1136
+ /** Assumed number of requests in a typical 2-hour session */
1137
+ REQUESTS_PER_SESSION: 60
1138
+ };
1139
+ var CACHE_BUSTERS = [
1140
+ { pattern: /\d{4}-\d{2}-\d{2}/g, label: "Date string" },
1141
+ { pattern: /\d{2}:\d{2}:\d{2}/g, label: "Time string" },
1142
+ { pattern: /process\.env\.\w+/g, label: "Environment variable reference" },
1143
+ { pattern: /\$\{.*?\}/g, label: "Template literal with variable" },
1144
+ { pattern: /Last updated:.*/gi, label: '"Last updated" timestamp' },
1145
+ { pattern: /Version:.*\d+\.\d+/gi, label: "Version string" },
1146
+ { pattern: /Generated by.*/gi, label: "Generator comment" }
1147
+ ];
1148
+
1149
+ // src/analyzer/waste-detector.ts
1150
+ function warn(code, severity, message, suggestion, estimatedSavings, lineNumber) {
1151
+ return { code, severity, message, suggestion, estimatedSavings, lineNumber };
1152
+ }
1153
+ function detectClaudeMdWarnings(content, tokenCount, referenceCount) {
1154
+ const warnings = [];
1155
+ if (tokenCount > WASTE_THRESHOLDS.MAX_CLAUDEMD_TOKENS) {
1156
+ const excess = tokenCount - WASTE_THRESHOLDS.MAX_CLAUDEMD_TOKENS;
1157
+ const pct = Math.round((tokenCount / WASTE_THRESHOLDS.MAX_CLAUDEMD_TOKENS - 1) * 100);
1158
+ warnings.push(
1159
+ warn(
1160
+ "OVERSIZED_CLAUDEMD",
1161
+ "error",
1162
+ `CLAUDE.md is ${tokenCount.toLocaleString()} tokens \u2014 ${pct}% over the ${WASTE_THRESHOLDS.MAX_CLAUDEMD_TOKENS.toLocaleString()} token recommendation`,
1163
+ "Run `claudectx optimize --claudemd` to split into demand-loaded files",
1164
+ excess
1165
+ )
1166
+ );
1167
+ }
1168
+ const lines = content.split("\n");
1169
+ for (const { pattern, label } of CACHE_BUSTERS) {
1170
+ pattern.lastIndex = 0;
1171
+ for (let i = 0; i < lines.length; i++) {
1172
+ if (pattern.test(lines[i])) {
1173
+ warnings.push(
1174
+ warn(
1175
+ "CACHE_BUSTING_CONTENT",
1176
+ "warning",
1177
+ `${label} on line ${i + 1} breaks prompt caching`,
1178
+ "Remove or externalize dynamic content \u2014 static CLAUDE.md saves ~88% on repeated requests",
1179
+ 0,
1180
+ i + 1
1181
+ )
1182
+ );
1183
+ break;
1184
+ }
1185
+ pattern.lastIndex = 0;
1186
+ }
1187
+ }
1188
+ if (referenceCount > WASTE_THRESHOLDS.MAX_REFERENCE_COUNT) {
1189
+ warnings.push(
1190
+ warn(
1191
+ "TOO_MANY_REFERENCES",
1192
+ "warning",
1193
+ `CLAUDE.md has ${referenceCount} @referenced files \u2014 consider consolidating`,
1194
+ "Group related references into fewer files to reduce overhead",
1195
+ 0
1196
+ )
1197
+ );
1198
+ }
1199
+ return warnings;
1200
+ }
1201
+ function detectMemoryWarnings(content, tokenCount) {
1202
+ const warnings = [];
1203
+ if (tokenCount > WASTE_THRESHOLDS.MAX_MEMORY_TOKENS) {
1204
+ const excess = tokenCount - WASTE_THRESHOLDS.MAX_MEMORY_TOKENS;
1205
+ warnings.push(
1206
+ warn(
1207
+ "OVERSIZED_MEMORY",
1208
+ "warning",
1209
+ `MEMORY.md is ${tokenCount.toLocaleString()} tokens \u2014 over the ${WASTE_THRESHOLDS.MAX_MEMORY_TOKENS.toLocaleString()} token recommendation`,
1210
+ "Run `claudectx compress --prune --days 30` to prune old entries",
1211
+ excess
1212
+ )
1213
+ );
1214
+ }
1215
+ return warnings;
1216
+ }
1217
+ function detectReferenceFileWarnings(filePath, content, tokenCount) {
1218
+ const warnings = [];
1219
+ if (tokenCount > WASTE_THRESHOLDS.MAX_REFERENCE_FILE_TOKENS) {
1220
+ warnings.push(
1221
+ warn(
1222
+ "LARGE_REFERENCE_FILE",
1223
+ "warning",
1224
+ `Referenced file ${path3.basename(filePath)} is ${tokenCount.toLocaleString()} tokens`,
1225
+ "Split large reference files or move rarely-needed sections to separate files",
1226
+ tokenCount - WASTE_THRESHOLDS.MAX_REFERENCE_FILE_TOKENS
1227
+ )
1228
+ );
1229
+ }
1230
+ return warnings;
1231
+ }
1232
+ function detectMissingIgnoreFile(projectRoot) {
1233
+ const ignorePath = path3.join(projectRoot, ".claudeignore");
1234
+ if (!fs2.existsSync(ignorePath)) {
1235
+ return warn(
1236
+ "MISSING_IGNOREFILE",
1237
+ "warning",
1238
+ "No .claudeignore file found \u2014 Claude may read node_modules, .git, dist/ etc.",
1239
+ "Run `claudectx optimize --ignorefile` to generate one",
1240
+ 0
1241
+ );
1242
+ }
1243
+ return null;
1244
+ }
1245
+ function detectNoCachingConfigured(projectRoot, claudeMdContent) {
1246
+ const settingsPath = path3.join(projectRoot, ".claude", "settings.json");
1247
+ if (!fs2.existsSync(settingsPath) && claudeMdContent) {
1248
+ return warn(
1249
+ "NO_CACHING_CONFIGURED",
1250
+ "info",
1251
+ "Prompt caching may not be configured \u2014 static context is re-billed on every request",
1252
+ "Run `claudectx optimize --cache` for caching recommendations",
1253
+ 0
1254
+ );
1255
+ }
1256
+ return null;
1257
+ }
1258
+ function detectRedundantContent(claudeMdContent, memoryContent) {
1259
+ if (!memoryContent) return null;
1260
+ const claudeLines = new Set(
1261
+ claudeMdContent.split("\n").map((l) => l.trim()).filter((l) => l.length > 20)
1262
+ );
1263
+ const memoryLines = memoryContent.split("\n").map((l) => l.trim()).filter((l) => l.length > 20);
1264
+ const duplicates = memoryLines.filter((l) => claudeLines.has(l));
1265
+ if (duplicates.length > 3) {
1266
+ return warn(
1267
+ "REDUNDANT_CONTENT",
1268
+ "info",
1269
+ `${duplicates.length} lines appear in both CLAUDE.md and MEMORY.md`,
1270
+ "Remove duplicated content from MEMORY.md \u2014 CLAUDE.md is already injected every request",
1271
+ countTokens(duplicates.join("\n"))
1272
+ );
1273
+ }
1274
+ return null;
1275
+ }
1276
+
1277
+ // src/analyzer/cost-calculator.ts
1278
+ init_esm_shims();
1279
+ init_models();
1280
+ function sessionCost(tokensPerRequest, model) {
1281
+ const perRequest = calculateCost(tokensPerRequest, model);
1282
+ return {
1283
+ perRequest,
1284
+ perSession: perRequest * SESSION_DEFAULTS.REQUESTS_PER_SESSION,
1285
+ perHour: perRequest * 60
1286
+ };
1287
+ }
1288
+ function calculatePotentialSavings(currentTokens, savableTokens, model) {
1289
+ const savedTokens = Math.min(savableTokens, currentTokens);
1290
+ const savedPercent = currentTokens > 0 ? Math.round(savedTokens / currentTokens * 100) : 0;
1291
+ const savedCostPerSession = calculateCost(savedTokens, model) * SESSION_DEFAULTS.REQUESTS_PER_SESSION;
1292
+ return { savedTokens, savedPercent, savedCostPerSession };
1293
+ }
1294
+ function formatCost(usd) {
1295
+ if (usd < 0.01) return "$0.00";
1296
+ return `$${usd.toFixed(2)}`;
1297
+ }
1298
+
1299
+ // src/analyzer/index.ts
1300
+ init_models();
1301
+ var ContextAnalyzer = class {
1302
+ constructor(model) {
1303
+ this.model = model;
1304
+ }
1305
+ model;
1306
+ async analyze(projectPath) {
1307
+ const ctx = parseContext(projectPath);
1308
+ const components = [];
1309
+ const allWarnings = [];
1310
+ components.push({
1311
+ name: "System prompt (built-in)",
1312
+ type: "system-prompt",
1313
+ tokenCount: BUILTIN_OVERHEAD.SYSTEM_PROMPT,
1314
+ estimatedCostPerRequest: calculateCost(BUILTIN_OVERHEAD.SYSTEM_PROMPT, this.model),
1315
+ warnings: []
1316
+ });
1317
+ components.push({
1318
+ name: "Tool definitions (built-in)",
1319
+ type: "tool-definitions",
1320
+ tokenCount: BUILTIN_OVERHEAD.TOOL_DEFINITIONS,
1321
+ estimatedCostPerRequest: calculateCost(BUILTIN_OVERHEAD.TOOL_DEFINITIONS, this.model),
1322
+ warnings: []
1323
+ });
1324
+ if (ctx.mcpToolCount > 0) {
1325
+ const mcpTokens = ctx.mcpToolCount * BUILTIN_OVERHEAD.MCP_PER_TOOL;
1326
+ components.push({
1327
+ name: `MCP schemas (${ctx.mcpToolCount} tools)`,
1328
+ type: "mcp-schemas",
1329
+ tokenCount: mcpTokens,
1330
+ estimatedCostPerRequest: calculateCost(mcpTokens, this.model),
1331
+ warnings: []
1332
+ });
1333
+ }
1334
+ if (ctx.projectClaudeMd) {
1335
+ const tokenCount = countTokens(ctx.projectClaudeMd.content);
1336
+ const refCount = ctx.referencedFiles.length;
1337
+ const warnings = detectClaudeMdWarnings(ctx.projectClaudeMd.content, tokenCount, refCount);
1338
+ allWarnings.push(...warnings);
1339
+ components.push({
1340
+ name: "CLAUDE.md (project)",
1341
+ type: "claude-md",
1342
+ filePath: ctx.projectClaudeMd.filePath,
1343
+ tokenCount,
1344
+ estimatedCostPerRequest: calculateCost(tokenCount, this.model),
1345
+ warnings
1346
+ });
1347
+ }
1348
+ if (ctx.userClaudeMd) {
1349
+ const tokenCount = countTokens(ctx.userClaudeMd.content);
1350
+ const warnings = detectClaudeMdWarnings(ctx.userClaudeMd.content, tokenCount, 0);
1351
+ allWarnings.push(...warnings);
1352
+ components.push({
1353
+ name: "CLAUDE.md (user ~/.claude/)",
1354
+ type: "claude-md",
1355
+ filePath: ctx.userClaudeMd.filePath,
1356
+ tokenCount,
1357
+ estimatedCostPerRequest: calculateCost(tokenCount, this.model),
1358
+ warnings
1359
+ });
1360
+ }
1361
+ if (ctx.memoryMd) {
1362
+ const tokenCount = countTokens(ctx.memoryMd.content);
1363
+ const warnings = detectMemoryWarnings(ctx.memoryMd.content, tokenCount);
1364
+ allWarnings.push(...warnings);
1365
+ components.push({
1366
+ name: "MEMORY.md",
1367
+ type: "memory",
1368
+ filePath: ctx.memoryMd.filePath,
1369
+ tokenCount,
1370
+ estimatedCostPerRequest: calculateCost(tokenCount, this.model),
1371
+ warnings
1372
+ });
1373
+ }
1374
+ for (const ref of ctx.referencedFiles) {
1375
+ const tokenCount = countTokens(ref.content);
1376
+ const warnings = detectReferenceFileWarnings(ref.filePath, ref.content, tokenCount);
1377
+ allWarnings.push(...warnings);
1378
+ components.push({
1379
+ name: `@${ref.referencedAs}`,
1380
+ type: "reference-file",
1381
+ filePath: ref.filePath,
1382
+ tokenCount,
1383
+ estimatedCostPerRequest: calculateCost(tokenCount, this.model),
1384
+ warnings
1385
+ });
1386
+ }
1387
+ const projectRoot = ctx.projectRoot ?? projectPath;
1388
+ const missingIgnore = detectMissingIgnoreFile(projectRoot);
1389
+ if (missingIgnore) allWarnings.push(missingIgnore);
1390
+ const noCache = detectNoCachingConfigured(
1391
+ projectRoot,
1392
+ ctx.projectClaudeMd?.content
1393
+ );
1394
+ if (noCache) allWarnings.push(noCache);
1395
+ const redundant = detectRedundantContent(
1396
+ ctx.projectClaudeMd?.content ?? "",
1397
+ ctx.memoryMd?.content
1398
+ );
1399
+ if (redundant) allWarnings.push(redundant);
1400
+ const totalTokensPerRequest = components.reduce((s, c) => s + c.tokenCount, 0);
1401
+ const totalSavableTokens = allWarnings.reduce((s, w) => s + w.estimatedSavings, 0);
1402
+ const costs = sessionCost(totalTokensPerRequest, this.model);
1403
+ const savings = calculatePotentialSavings(
1404
+ totalTokensPerRequest,
1405
+ totalSavableTokens,
1406
+ this.model
1407
+ );
1408
+ return {
1409
+ projectPath,
1410
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1411
+ model: this.model,
1412
+ components,
1413
+ totalTokensPerRequest,
1414
+ estimatedCostPerSession: costs.perSession,
1415
+ warnings: allWarnings,
1416
+ optimizedTokensPerRequest: totalTokensPerRequest - savings.savedTokens,
1417
+ potentialSavingsPercent: savings.savedPercent
1418
+ };
1419
+ }
1420
+ };
1421
+
1422
+ // src/commands/analyze.ts
1423
+ init_models();
1424
+ function statusIcon(component) {
1425
+ if (component.warnings.length === 0) return chalk.green("\u2713");
1426
+ const hasError = component.warnings.some((w) => w.severity === "error");
1427
+ if (hasError) return chalk.red("\u2716");
1428
+ return chalk.yellow("\u26A0");
1429
+ }
1430
+ function renderReport(report) {
1431
+ const contextPct = (report.totalTokensPerRequest / 2e5 * 100).toFixed(1);
1432
+ const header = [
1433
+ chalk.bold("claudectx \u2014 Context Analysis"),
1434
+ chalk.dim(`Project: ${report.projectPath}`),
1435
+ "",
1436
+ `${chalk.bold("Tokens/request:")} ${chalk.cyan(report.totalTokensPerRequest.toLocaleString())} ${chalk.bold("Session cost:")} ${chalk.yellow(formatCost(report.estimatedCostPerSession))}`,
1437
+ `${chalk.bold("Model:")} ${report.model} ${chalk.bold("Context used:")} ${contextPct}% of 200K window`
1438
+ ].join("\n");
1439
+ process.stdout.write(
1440
+ boxen(header, {
1441
+ padding: 1,
1442
+ borderStyle: "double",
1443
+ borderColor: "cyan"
1444
+ }) + "\n\n"
1445
+ );
1446
+ const table = new Table({
1447
+ head: [
1448
+ chalk.bold("Component"),
1449
+ chalk.bold("Tokens"),
1450
+ chalk.bold("Cost/req"),
1451
+ chalk.bold("Status")
1452
+ ],
1453
+ colWidths: [38, 12, 12, 10],
1454
+ style: { head: [], border: [] }
1455
+ });
1456
+ for (const c of report.components) {
1457
+ table.push([
1458
+ c.name,
1459
+ c.tokenCount.toLocaleString(),
1460
+ formatCost(c.estimatedCostPerRequest),
1461
+ statusIcon(c)
1462
+ ]);
1463
+ }
1464
+ table.push([
1465
+ chalk.bold("TOTAL (per request)"),
1466
+ chalk.bold(report.totalTokensPerRequest.toLocaleString()),
1467
+ chalk.bold(formatCost(report.components.reduce((s, c) => s + c.estimatedCostPerRequest, 0))),
1468
+ ""
1469
+ ]);
1470
+ process.stdout.write(table.toString() + "\n");
1471
+ if (report.warnings.length === 0) {
1472
+ process.stdout.write("\n" + chalk.green("\u2714 No optimization opportunities found. Looking good!\n"));
1473
+ } else {
1474
+ process.stdout.write(
1475
+ "\n" + chalk.yellow(`\u26A0 ${report.warnings.length} optimization ${report.warnings.length === 1 ? "opportunity" : "opportunities"} found:
1476
+
1477
+ `)
1478
+ );
1479
+ report.warnings.forEach((w, i) => {
1480
+ const icon = w.severity === "error" ? chalk.red("\u2716") : w.severity === "warning" ? chalk.yellow("\u26A0") : chalk.blue("\u2139");
1481
+ const lineInfo = w.lineNumber ? ` (line ${w.lineNumber})` : "";
1482
+ process.stdout.write(` ${chalk.bold(`[${i + 1}]`)} ${icon} ${w.message}${lineInfo}
1483
+ `);
1484
+ process.stdout.write(` ${chalk.dim("\u2192")} ${w.suggestion}
1485
+ `);
1486
+ if (w.estimatedSavings > 0) {
1487
+ process.stdout.write(
1488
+ ` ${chalk.dim("\u2192")} Potential savings: ~${w.estimatedSavings.toLocaleString()} tokens/request
1489
+ `
1490
+ );
1491
+ }
1492
+ process.stdout.write("\n");
1493
+ });
1494
+ process.stdout.write(
1495
+ chalk.dim(
1496
+ ` \u{1F4A1} Run ${chalk.cyan("claudectx optimize")} to fix all issues automatically.
1497
+ \u{1F4A1} Run ${chalk.cyan("claudectx optimize --dry-run")} to preview changes first.
1498
+ `
1499
+ ) + "\n"
1500
+ );
1501
+ }
1502
+ if (report.potentialSavingsPercent > 0) {
1503
+ process.stdout.write(
1504
+ chalk.dim(
1505
+ ` Potential savings: ${report.potentialSavingsPercent}% (${(report.totalTokensPerRequest - report.optimizedTokensPerRequest).toLocaleString()} tokens)
1506
+
1507
+ `
1508
+ )
1509
+ );
1510
+ }
1511
+ process.stdout.write(
1512
+ chalk.dim(" \u2B50 If claudectx saved you money, star the repo: https://github.com/Horilla/claudectx\n\n")
1513
+ );
1514
+ }
1515
+ async function analyzeCommand(options) {
1516
+ const targetPath = path4.resolve(options.path ?? process.cwd());
1517
+ const model = resolveModel(options.model ?? "sonnet");
1518
+ const analyzer = new ContextAnalyzer(model);
1519
+ async function run() {
1520
+ try {
1521
+ const report = await analyzer.analyze(targetPath);
1522
+ if (options.json) {
1523
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
1524
+ return;
1525
+ }
1526
+ renderReport(report);
1527
+ } catch (err) {
1528
+ process.stderr.write(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}
1529
+ `));
1530
+ process.exit(1);
1531
+ }
1532
+ }
1533
+ await run();
1534
+ if (options.watch) {
1535
+ const { watch: watch2 } = await import("fs");
1536
+ process.stderr.write(chalk.dim("Watching for changes (Ctrl+C to stop)...\n"));
1537
+ let debounce = null;
1538
+ watch2(targetPath, { recursive: true }, (_event, filename) => {
1539
+ if (!filename?.includes("CLAUDE") && !filename?.includes("MEMORY")) return;
1540
+ if (debounce) clearTimeout(debounce);
1541
+ debounce = setTimeout(async () => {
1542
+ process.stdout.write("\x1Bc");
1543
+ process.stderr.write(chalk.dim(`Re-analyzing after change to ${filename}...
1544
+
1545
+ `));
1546
+ await run();
1547
+ }, 300);
1548
+ });
1549
+ await new Promise(() => {
1550
+ });
1551
+ }
1552
+ }
1553
+
1554
+ // src/commands/optimize.ts
1555
+ init_esm_shims();
1556
+ import * as fs7 from "fs";
1557
+ import * as path8 from "path";
1558
+ import chalk3 from "chalk";
1559
+ import boxen2 from "boxen";
1560
+ import { checkbox, confirm } from "@inquirer/prompts";
1561
+
1562
+ // src/shared/logger.ts
1563
+ init_esm_shims();
1564
+ import chalk2 from "chalk";
1565
+ var logger = {
1566
+ info: (msg) => process.stderr.write(chalk2.blue("\u2139 ") + msg + "\n"),
1567
+ warn: (msg) => process.stderr.write(chalk2.yellow("\u26A0 ") + msg + "\n"),
1568
+ error: (msg) => process.stderr.write(chalk2.red("\u2716 ") + msg + "\n"),
1569
+ success: (msg) => process.stderr.write(chalk2.green("\u2714 ") + msg + "\n"),
1570
+ dim: (msg) => process.stderr.write(chalk2.dim(msg) + "\n")
1571
+ };
1572
+
1573
+ // src/optimizer/ignorefile-generator.ts
1574
+ init_esm_shims();
1575
+ import * as fs3 from "fs";
1576
+ import * as path5 from "path";
1577
+ var ALWAYS_IGNORE = `# .claudeignore \u2014 generated by claudectx
1578
+ # Prevents Claude Code from accidentally reading large binary/generated files.
1579
+ # Syntax is identical to .gitignore.
1580
+
1581
+ # Version control internals
1582
+ .git/
1583
+
1584
+ # Dependencies
1585
+ node_modules/
1586
+ vendor/
1587
+ .venv/
1588
+ venv/
1589
+ env/
1590
+ .env/
1591
+
1592
+ # Build output
1593
+ dist/
1594
+ build/
1595
+ out/
1596
+ .next/
1597
+ .nuxt/
1598
+ .output/
1599
+ target/
1600
+
1601
+ # Bytecode & compiled files
1602
+ *.pyc
1603
+ *.pyo
1604
+ __pycache__/
1605
+ *.class
1606
+ *.o
1607
+ *.a
1608
+ *.so
1609
+ *.dylib
1610
+
1611
+ # Logs & databases
1612
+ *.log
1613
+ logs/
1614
+ *.sqlite3
1615
+ *.sqlite
1616
+ *.db
1617
+
1618
+ # OS artefacts
1619
+ .DS_Store
1620
+ Thumbs.db
1621
+ desktop.ini
1622
+
1623
+ # Environment & secrets (never let Claude read these)
1624
+ .env
1625
+ .env.local
1626
+ .env.*.local
1627
+ *.pem
1628
+ *.key
1629
+ *.cert
1630
+ secrets.json
1631
+
1632
+ # Coverage & test caches
1633
+ coverage/
1634
+ .coverage
1635
+ htmlcov/
1636
+ .cache/
1637
+ .pytest_cache/
1638
+ .mypy_cache/
1639
+ .ruff_cache/
1640
+ .tox/
1641
+
1642
+ # IDE files
1643
+ .idea/
1644
+ .vscode/
1645
+ *.swp
1646
+ *.swo
1647
+ *~
1648
+
1649
+ # Large media / binary assets
1650
+ *.jpg
1651
+ *.jpeg
1652
+ *.png
1653
+ *.gif
1654
+ *.ico
1655
+ *.mp4
1656
+ *.mp3
1657
+ *.pdf
1658
+ *.zip
1659
+ *.tar.gz
1660
+ *.woff
1661
+ *.woff2
1662
+ *.ttf
1663
+ *.eot
1664
+ `;
1665
+ var PYTHON_EXTRA = `
1666
+ # Python / Django extras
1667
+ migrations/
1668
+ staticfiles/
1669
+ media/
1670
+ .eggs/
1671
+ *.egg-info/
1672
+ pip-wheel-metadata/
1673
+ `;
1674
+ var NODE_EXTRA = `
1675
+ # Node.js lock files (large, rarely useful to Claude)
1676
+ package-lock.json
1677
+ yarn.lock
1678
+ pnpm-lock.yaml
1679
+ .yarn/
1680
+ `;
1681
+ var RUST_EXTRA = `
1682
+ # Rust
1683
+ Cargo.lock
1684
+ `;
1685
+ var GO_EXTRA = `
1686
+ # Go
1687
+ go.sum
1688
+ `;
1689
+ function detectProjectTypes(projectRoot) {
1690
+ const types = [];
1691
+ if (fs3.existsSync(path5.join(projectRoot, "package.json"))) types.push("node");
1692
+ if (fs3.existsSync(path5.join(projectRoot, "manage.py")) || fs3.existsSync(path5.join(projectRoot, "requirements.txt")) || fs3.existsSync(path5.join(projectRoot, "pyproject.toml")))
1693
+ types.push("python");
1694
+ if (fs3.existsSync(path5.join(projectRoot, "Cargo.toml"))) types.push("rust");
1695
+ if (fs3.existsSync(path5.join(projectRoot, "go.mod"))) types.push("go");
1696
+ return types;
1697
+ }
1698
+ function generateIgnorefile(projectRoot) {
1699
+ const filePath = path5.join(projectRoot, ".claudeignore");
1700
+ const existed = fs3.existsSync(filePath);
1701
+ const projectTypes = detectProjectTypes(projectRoot);
1702
+ let content = ALWAYS_IGNORE;
1703
+ if (projectTypes.includes("python")) content += PYTHON_EXTRA;
1704
+ if (projectTypes.includes("node")) content += NODE_EXTRA;
1705
+ if (projectTypes.includes("rust")) content += RUST_EXTRA;
1706
+ if (projectTypes.includes("go")) content += GO_EXTRA;
1707
+ return { filePath, content, existed, projectTypes };
1708
+ }
1709
+ function writeIgnorefile(result) {
1710
+ if (result.existed) {
1711
+ const existing = fs3.readFileSync(result.filePath, "utf-8");
1712
+ fs3.writeFileSync(result.filePath, existing.trimEnd() + "\n\n" + result.content, "utf-8");
1713
+ } else {
1714
+ fs3.writeFileSync(result.filePath, result.content, "utf-8");
1715
+ }
1716
+ }
1717
+
1718
+ // src/optimizer/claudemd-splitter.ts
1719
+ init_esm_shims();
1720
+ init_tokenizer();
1721
+ import * as fs4 from "fs";
1722
+ import * as path6 from "path";
1723
+ var SPLIT_MIN_TOKENS = 300;
1724
+ function slugify(title) {
1725
+ return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1726
+ }
1727
+ function parseSections(content) {
1728
+ const lines = content.split("\n");
1729
+ const sections = [];
1730
+ let currentLines = [];
1731
+ let currentTitle = "";
1732
+ let isPreamble = true;
1733
+ const flush = () => {
1734
+ if (currentLines.length === 0 && !isPreamble) return;
1735
+ const text = currentLines.join("\n");
1736
+ sections.push({
1737
+ title: currentTitle,
1738
+ content: text,
1739
+ tokens: countTokens(text),
1740
+ isPreamble
1741
+ });
1742
+ };
1743
+ for (const line of lines) {
1744
+ if (line.startsWith("## ")) {
1745
+ flush();
1746
+ currentTitle = line.slice(3).trim();
1747
+ currentLines = [line];
1748
+ isPreamble = false;
1749
+ } else {
1750
+ currentLines.push(line);
1751
+ }
1752
+ }
1753
+ flush();
1754
+ return sections;
1755
+ }
1756
+ function planSplit(claudeMdPath, sectionsToExtract) {
1757
+ const content = fs4.readFileSync(claudeMdPath, "utf-8");
1758
+ const sections = parseSections(content);
1759
+ const claudeDir = path6.join(path6.dirname(claudeMdPath), ".claude");
1760
+ const extractedFiles = [];
1761
+ let newContent = "";
1762
+ let tokensSaved = 0;
1763
+ const usedSlugs = /* @__PURE__ */ new Map();
1764
+ for (const section of sections) {
1765
+ if (!section.isPreamble && sectionsToExtract.includes(section.title)) {
1766
+ let slug = slugify(section.title);
1767
+ const count = usedSlugs.get(slug) ?? 0;
1768
+ if (count > 0) slug = `${slug}-${count}`;
1769
+ usedSlugs.set(slug, count + 1);
1770
+ const filename = `${slug}.md`;
1771
+ const relRefPath = `.claude/${filename}`;
1772
+ const filePath = path6.join(claudeDir, filename);
1773
+ const refBlock = `## ${section.title}
1774
+
1775
+ @${relRefPath}
1776
+ `;
1777
+ newContent += refBlock + "\n";
1778
+ extractedFiles.push({
1779
+ filePath,
1780
+ content: section.content,
1781
+ sectionTitle: section.title,
1782
+ refPath: relRefPath
1783
+ });
1784
+ tokensSaved += section.tokens - countTokens(refBlock);
1785
+ } else {
1786
+ newContent += section.content.trimEnd() + "\n\n";
1787
+ }
1788
+ }
1789
+ return {
1790
+ claudeMdPath,
1791
+ newClaudeMd: newContent.trimEnd() + "\n",
1792
+ extractedFiles,
1793
+ tokensSaved: Math.max(0, tokensSaved)
1794
+ };
1795
+ }
1796
+ function applySplit(result) {
1797
+ if (result.extractedFiles.length === 0) return;
1798
+ const claudeDir = path6.dirname(result.extractedFiles[0].filePath);
1799
+ if (!fs4.existsSync(claudeDir)) {
1800
+ fs4.mkdirSync(claudeDir, { recursive: true });
1801
+ }
1802
+ for (const file of result.extractedFiles) {
1803
+ fs4.writeFileSync(file.filePath, file.content, "utf-8");
1804
+ }
1805
+ fs4.writeFileSync(result.claudeMdPath, result.newClaudeMd, "utf-8");
1806
+ }
1807
+
1808
+ // src/optimizer/cache-applier.ts
1809
+ init_esm_shims();
1810
+ import * as fs5 from "fs";
1811
+ function findCacheBusters(content) {
1812
+ const fixes = [];
1813
+ const lines = content.split("\n");
1814
+ for (let i = 0; i < lines.length; i++) {
1815
+ const line = lines[i];
1816
+ for (const buster of CACHE_BUSTERS) {
1817
+ const re = new RegExp(buster.pattern.source, "i");
1818
+ if (re.test(line)) {
1819
+ fixes.push({
1820
+ label: buster.label,
1821
+ lineNumber: i + 1,
1822
+ originalLine: line,
1823
+ // Comment-out the line so content is still vaguely visible in the file
1824
+ fixedLine: `<!-- claudectx removed cache-busting content (${buster.label}): ${line.trim()} -->`
1825
+ });
1826
+ break;
1827
+ }
1828
+ }
1829
+ }
1830
+ return fixes;
1831
+ }
1832
+ function applyCacheFixes(content, fixes) {
1833
+ const lines = content.split("\n");
1834
+ for (const fix of fixes) {
1835
+ lines[fix.lineNumber - 1] = fix.fixedLine;
1836
+ }
1837
+ return lines.join("\n");
1838
+ }
1839
+ function planCacheFixes(claudeMdPath) {
1840
+ const content = fs5.readFileSync(claudeMdPath, "utf-8");
1841
+ const fixes = findCacheBusters(content);
1842
+ return { fixes, newContent: applyCacheFixes(content, fixes) };
1843
+ }
1844
+ function applyAndWriteCacheFixes(claudeMdPath, result) {
1845
+ fs5.writeFileSync(claudeMdPath, result.newContent, "utf-8");
1846
+ }
1847
+
1848
+ // src/optimizer/hooks-installer.ts
1849
+ init_esm_shims();
1850
+ import * as fs6 from "fs";
1851
+ import * as path7 from "path";
1852
+ var CLAUDECTX_HOOKS = {
1853
+ PostToolUse: [
1854
+ {
1855
+ // Pipe the hook JSON payload to `claudectx watch --log-stdin`.
1856
+ // Claude Code passes { tool_name, tool_input, tool_response, session_id }
1857
+ // via stdin when the PostToolUse hook fires.
1858
+ matcher: "Read",
1859
+ hooks: [
1860
+ {
1861
+ type: "command",
1862
+ command: "claudectx watch --log-stdin"
1863
+ }
1864
+ ]
1865
+ }
1866
+ ]
1867
+ };
1868
+ function planHooksInstall(projectRoot) {
1869
+ const claudeDir = path7.join(projectRoot, ".claude");
1870
+ const settingsPath = path7.join(claudeDir, "settings.local.json");
1871
+ const existed = fs6.existsSync(settingsPath);
1872
+ let existing = {};
1873
+ if (existed) {
1874
+ try {
1875
+ existing = JSON.parse(fs6.readFileSync(settingsPath, "utf-8"));
1876
+ } catch {
1877
+ existing = {};
1878
+ }
1879
+ }
1880
+ const existingHooks = existing.hooks ?? {};
1881
+ const existingPostToolUse = existingHooks.PostToolUse ?? [];
1882
+ const alreadyInstalled = existingPostToolUse.some(
1883
+ (h) => typeof h === "object" && h !== null && h.matcher === "Read" && JSON.stringify(h).includes("claudectx")
1884
+ );
1885
+ const mergedPostToolUse = alreadyInstalled ? existingPostToolUse : [...existingPostToolUse, ...CLAUDECTX_HOOKS.PostToolUse];
1886
+ const mergedSettings = {
1887
+ ...existing,
1888
+ hooks: {
1889
+ ...existingHooks,
1890
+ PostToolUse: mergedPostToolUse
1891
+ }
1892
+ };
1893
+ return { settingsPath, existed, mergedSettings };
1894
+ }
1895
+ function applyHooksInstall(result) {
1896
+ const dir = path7.dirname(result.settingsPath);
1897
+ if (!fs6.existsSync(dir)) {
1898
+ fs6.mkdirSync(dir, { recursive: true });
1899
+ }
1900
+ fs6.writeFileSync(result.settingsPath, JSON.stringify(result.mergedSettings, null, 2) + "\n", "utf-8");
1901
+ }
1902
+ function isAlreadyInstalled(projectRoot) {
1903
+ const settingsPath = path7.join(projectRoot, ".claude", "settings.local.json");
1904
+ if (!fs6.existsSync(settingsPath)) return false;
1905
+ try {
1906
+ const settings = JSON.parse(fs6.readFileSync(settingsPath, "utf-8"));
1907
+ const postToolUse = settings?.hooks?.PostToolUse ?? [];
1908
+ return postToolUse.some((h) => h.matcher === "Read");
1909
+ } catch {
1910
+ return false;
1911
+ }
1912
+ }
1913
+
1914
+ // src/commands/optimize.ts
1915
+ async function optimizeCommand(options) {
1916
+ const projectPath = options.path ? path8.resolve(options.path) : findProjectRoot() ?? process.cwd();
1917
+ const dryRun = options.dryRun ?? false;
1918
+ const autoApply = options.apply ?? false;
1919
+ const specificMode = options.claudemd || options.ignorefile || options.cache || options.hooks;
1920
+ console.log(
1921
+ boxen2(
1922
+ chalk3.bold("claudectx \u2014 Optimize") + "\n" + chalk3.dim(`Project: ${projectPath}`) + (dryRun ? "\n" + chalk3.yellow("Dry run \u2014 no files will be changed") : ""),
1923
+ { padding: 1, borderStyle: "round", borderColor: dryRun ? "yellow" : "cyan" }
1924
+ )
1925
+ );
1926
+ logger.info("Analyzing context...");
1927
+ const analyzer = new ContextAnalyzer("claude-sonnet-4-6");
1928
+ const report = await analyzer.analyze(projectPath);
1929
+ const hasWarning = (code) => report.warnings.some((w) => w.code === code);
1930
+ const fixes = [
1931
+ {
1932
+ id: "ignorefile",
1933
+ label: "Generate .claudeignore",
1934
+ detail: "Prevents Claude from reading node_modules/, dist/, .git/, etc.",
1935
+ available: hasWarning("MISSING_IGNOREFILE") || !!options.ignorefile
1936
+ },
1937
+ {
1938
+ id: "claudemd",
1939
+ label: `Split CLAUDE.md into @files`,
1940
+ detail: `Extract large sections to demand-loaded files (saves tokens per request)`,
1941
+ available: hasWarning("OVERSIZED_CLAUDEMD") || !!options.claudemd
1942
+ },
1943
+ {
1944
+ id: "cache",
1945
+ label: "Remove cache-busting content",
1946
+ detail: "Comment-out dynamic dates/timestamps that bust the prompt cache every request",
1947
+ available: hasWarning("CACHE_BUSTING_CONTENT") || !!options.cache
1948
+ },
1949
+ {
1950
+ id: "hooks",
1951
+ label: "Install session hooks",
1952
+ detail: "Track per-file token spend via PostToolUse hook in .claude/settings.local.json",
1953
+ available: !isAlreadyInstalled(projectPath) || !!options.hooks
1954
+ }
1955
+ ];
1956
+ const eligible = fixes.filter((f) => f.available);
1957
+ if (eligible.length === 0 && !specificMode) {
1958
+ logger.success(
1959
+ "Nothing to optimize! Run `claudectx analyze` to see the current token breakdown."
1960
+ );
1961
+ return;
1962
+ }
1963
+ let selected;
1964
+ if (specificMode) {
1965
+ selected = [
1966
+ options.ignorefile && "ignorefile",
1967
+ options.claudemd && "claudemd",
1968
+ options.cache && "cache",
1969
+ options.hooks && "hooks"
1970
+ ].filter((x) => !!x);
1971
+ } else if (autoApply || dryRun) {
1972
+ selected = eligible.map((f) => f.id);
1973
+ } else {
1974
+ selected = await checkbox({
1975
+ message: "Which optimizations would you like to apply?",
1976
+ choices: eligible.map((f) => ({
1977
+ name: `${chalk3.white(f.label)} ${chalk3.dim("\u2014")} ${chalk3.dim(f.detail)}`,
1978
+ value: f.id,
1979
+ checked: true
1980
+ }))
1981
+ });
1982
+ }
1983
+ if (selected.length === 0) {
1984
+ logger.info("Nothing selected \u2014 no changes made.");
1985
+ return;
1986
+ }
1987
+ for (const id of selected) {
1988
+ switch (id) {
1989
+ case "ignorefile":
1990
+ await runIgnorefile(projectPath, dryRun, autoApply);
1991
+ break;
1992
+ case "claudemd":
1993
+ await runClaudeMdSplit(projectPath, report, dryRun, autoApply);
1994
+ break;
1995
+ case "cache":
1996
+ await runCacheOptimization(projectPath, dryRun, autoApply);
1997
+ break;
1998
+ case "hooks":
1999
+ await runHooks(projectPath, dryRun, autoApply);
2000
+ break;
2001
+ }
2002
+ }
2003
+ console.log("");
2004
+ if (dryRun) {
2005
+ logger.warn("Dry run complete. Re-run without --dry-run to apply changes.");
2006
+ } else {
2007
+ logger.success("Optimization complete! Run `claudectx analyze` to verify your savings.");
2008
+ }
2009
+ }
2010
+ async function runIgnorefile(projectRoot, dryRun, autoApply) {
2011
+ printSectionHeader(".claudeignore");
2012
+ const result = generateIgnorefile(projectRoot);
2013
+ if (result.existed) {
2014
+ logger.warn(".claudeignore already exists \u2014 new patterns will be appended.");
2015
+ } else {
2016
+ logger.info(`Will create: ${chalk3.cyan(result.filePath)}`);
2017
+ }
2018
+ logger.info(
2019
+ `Detected project types: ${result.projectTypes.length ? result.projectTypes.join(", ") : "generic"}`
2020
+ );
2021
+ if (dryRun) {
2022
+ console.log(chalk3.dim("\nPreview (first 20 lines):"));
2023
+ console.log(
2024
+ chalk3.dim(result.content.split("\n").slice(0, 20).join("\n") + "\n ...")
2025
+ );
2026
+ return;
2027
+ }
2028
+ const ok = autoApply || await confirm({
2029
+ message: result.existed ? "Append patterns to existing .claudeignore?" : "Create .claudeignore?",
2030
+ default: true
2031
+ });
2032
+ if (!ok) {
2033
+ logger.info("Skipped.");
2034
+ return;
2035
+ }
2036
+ writeIgnorefile(result);
2037
+ logger.success(`${result.existed ? "Updated" : "Created"} ${chalk3.cyan(result.filePath)}`);
2038
+ }
2039
+ async function runClaudeMdSplit(projectRoot, report, dryRun, autoApply) {
2040
+ printSectionHeader("CLAUDE.md \u2192 @files");
2041
+ const claudeMdPath = path8.join(projectRoot, "CLAUDE.md");
2042
+ if (!fs7.existsSync(claudeMdPath)) {
2043
+ logger.warn("No CLAUDE.md found \u2014 skipping.");
2044
+ return;
2045
+ }
2046
+ const content = fs7.readFileSync(claudeMdPath, "utf-8");
2047
+ const sections = parseSections(content);
2048
+ const largeSections = sections.filter(
2049
+ (s) => !s.isPreamble && s.tokens >= SPLIT_MIN_TOKENS
2050
+ );
2051
+ if (largeSections.length === 0) {
2052
+ logger.info(`No sections exceed ${SPLIT_MIN_TOKENS} tokens \u2014 nothing to extract.`);
2053
+ return;
2054
+ }
2055
+ const claudeMdWarning = report.warnings.find((w) => w.code === "OVERSIZED_CLAUDEMD");
2056
+ if (claudeMdWarning) {
2057
+ logger.warn(claudeMdWarning.message);
2058
+ }
2059
+ console.log("\n Large sections found:");
2060
+ for (const s of largeSections) {
2061
+ console.log(` ${chalk3.yellow("\u2022")} ${s.title} ${chalk3.dim(`(${s.tokens} tokens)`)}`);
2062
+ }
2063
+ let sectionsToExtract;
2064
+ if (autoApply || dryRun) {
2065
+ sectionsToExtract = largeSections.map((s) => s.title);
2066
+ } else {
2067
+ sectionsToExtract = await checkbox({
2068
+ message: "Select sections to extract into .claude/ @files:",
2069
+ choices: largeSections.map((s) => ({
2070
+ name: `${s.title} ${chalk3.dim(`\u2014 ${s.tokens} tokens`)}`,
2071
+ value: s.title,
2072
+ checked: true
2073
+ }))
2074
+ });
2075
+ }
2076
+ if (sectionsToExtract.length === 0) {
2077
+ logger.info("Skipped.");
2078
+ return;
2079
+ }
2080
+ const splitResult = planSplit(claudeMdPath, sectionsToExtract);
2081
+ if (dryRun) {
2082
+ console.log(
2083
+ chalk3.dim(
2084
+ `
2085
+ Would extract ${splitResult.extractedFiles.length} section(s) to .claude/`
2086
+ )
2087
+ );
2088
+ for (const f of splitResult.extractedFiles) {
2089
+ console.log(chalk3.dim(` \u2192 ${f.refPath} (${f.sectionTitle})`));
2090
+ }
2091
+ console.log(chalk3.dim(` Estimated savings: ~${splitResult.tokensSaved} tokens/request`));
2092
+ return;
2093
+ }
2094
+ const ok = autoApply || await confirm({
2095
+ message: `Extract ${sectionsToExtract.length} section(s) and update CLAUDE.md?`,
2096
+ default: true
2097
+ });
2098
+ if (!ok) {
2099
+ logger.info("Skipped.");
2100
+ return;
2101
+ }
2102
+ applySplit(splitResult);
2103
+ logger.success(
2104
+ `Extracted ${splitResult.extractedFiles.length} section(s). Saved ~${splitResult.tokensSaved} tokens/request.`
2105
+ );
2106
+ for (const f of splitResult.extractedFiles) {
2107
+ logger.info(` Created: ${chalk3.cyan(path8.relative(projectRoot, f.filePath))}`);
2108
+ }
2109
+ }
2110
+ async function runCacheOptimization(projectRoot, dryRun, autoApply) {
2111
+ printSectionHeader("Prompt cache optimisation");
2112
+ const claudeMdPath = path8.join(projectRoot, "CLAUDE.md");
2113
+ if (!fs7.existsSync(claudeMdPath)) {
2114
+ logger.warn("No CLAUDE.md found \u2014 skipping.");
2115
+ return;
2116
+ }
2117
+ const result = planCacheFixes(claudeMdPath);
2118
+ if (result.fixes.length === 0) {
2119
+ logger.success("No cache-busting patterns found in CLAUDE.md.");
2120
+ return;
2121
+ }
2122
+ console.log(`
2123
+ ${result.fixes.length} cache-busting line(s) detected:
2124
+ `);
2125
+ for (const fix of result.fixes) {
2126
+ console.log(
2127
+ ` ${chalk3.dim(`line ${fix.lineNumber}:`)} ${chalk3.red(fix.originalLine.trim())}`
2128
+ );
2129
+ console.log(` ${chalk3.dim("\u2192")} ${chalk3.green(fix.fixedLine)}`);
2130
+ console.log("");
2131
+ }
2132
+ if (dryRun) return;
2133
+ const ok = autoApply || await confirm({
2134
+ message: `Comment-out ${result.fixes.length} cache-busting line(s)?`,
2135
+ default: true
2136
+ });
2137
+ if (!ok) {
2138
+ logger.info("Skipped.");
2139
+ return;
2140
+ }
2141
+ applyAndWriteCacheFixes(claudeMdPath, result);
2142
+ logger.success(`Fixed ${result.fixes.length} cache-busting pattern(s) in CLAUDE.md.`);
2143
+ }
2144
+ async function runHooks(projectRoot, dryRun, autoApply) {
2145
+ printSectionHeader("Session hooks");
2146
+ const result = planHooksInstall(projectRoot);
2147
+ logger.info(
2148
+ `Settings file: ${chalk3.cyan(path8.relative(projectRoot, result.settingsPath))}`
2149
+ );
2150
+ logger.info(result.existed ? "Will merge with existing settings." : "Will create new file.");
2151
+ console.log(chalk3.dim("\n Hooks to install:"));
2152
+ console.log(
2153
+ chalk3.dim(" \u2022 PostToolUse \u2192 Read: track per-file token spend for `claudectx watch`")
2154
+ );
2155
+ if (dryRun) return;
2156
+ const ok = autoApply || await confirm({ message: "Install claudectx session hooks?", default: true });
2157
+ if (!ok) {
2158
+ logger.info("Skipped.");
2159
+ return;
2160
+ }
2161
+ applyHooksInstall(result);
2162
+ logger.success(
2163
+ `Hooks installed \u2192 ${chalk3.cyan(path8.relative(projectRoot, result.settingsPath))}`
2164
+ );
2165
+ }
2166
+ function printSectionHeader(title) {
2167
+ console.log("");
2168
+ console.log(chalk3.bold.cyan(`\u2500\u2500 ${title} ${"\u2500".repeat(Math.max(0, 50 - title.length))}`));
2169
+ }
2170
+
2171
+ // src/commands/watch.ts
2172
+ init_esm_shims();
2173
+ init_session_store();
2174
+ init_models();
2175
+ import * as path12 from "path";
2176
+ async function watchCommand(options) {
2177
+ if (options.logStdin) {
2178
+ await handleLogStdin();
2179
+ return;
2180
+ }
2181
+ if (options.clear) {
2182
+ const { clearStore: clearStore2 } = await Promise.resolve().then(() => (init_session_store(), session_store_exports));
2183
+ clearStore2();
2184
+ process.stdout.write("claudectx: session store cleared.\n");
2185
+ return;
2186
+ }
2187
+ if (!process.stdout.isTTY) {
2188
+ process.stderr.write(
2189
+ "claudectx watch: stdout is not a TTY \u2014 dashboard requires an interactive terminal.\n"
2190
+ );
2191
+ process.exit(1);
2192
+ }
2193
+ const model = options.model ? resolveModel(options.model) : "claude-sonnet-4-6";
2194
+ const { render } = await import("ink");
2195
+ const React2 = (await import("react")).default;
2196
+ const { Dashboard: Dashboard2 } = await Promise.resolve().then(() => (init_Dashboard(), Dashboard_exports));
2197
+ render(
2198
+ React2.createElement(Dashboard2, {
2199
+ model,
2200
+ sessionId: options.session
2201
+ })
2202
+ );
2203
+ }
2204
+ async function handleLogStdin() {
2205
+ const raw = await readStdin();
2206
+ if (!raw.trim()) return;
2207
+ try {
2208
+ const payload = JSON.parse(raw);
2209
+ const filePath = payload.tool_input?.file_path;
2210
+ if (filePath) {
2211
+ appendFileRead(path12.resolve(filePath), payload.session_id);
2212
+ }
2213
+ } catch {
2214
+ }
2215
+ }
2216
+ function readStdin() {
2217
+ return new Promise((resolve6) => {
2218
+ let data = "";
2219
+ process.stdin.setEncoding("utf-8");
2220
+ process.stdin.on("data", (chunk) => data += chunk);
2221
+ process.stdin.on("end", () => resolve6(data));
2222
+ setTimeout(() => resolve6(data), 500);
2223
+ });
2224
+ }
2225
+
2226
+ // src/commands/mcp.ts
2227
+ init_esm_shims();
2228
+ import * as path17 from "path";
2229
+ import chalk4 from "chalk";
2230
+
2231
+ // src/mcp/installer.ts
2232
+ init_esm_shims();
2233
+ import * as fs11 from "fs";
2234
+ import * as path13 from "path";
2235
+ var SERVER_NAME = "claudectx";
2236
+ var SERVER_ENTRY = {
2237
+ command: "claudectx",
2238
+ args: ["mcp"],
2239
+ type: "stdio"
2240
+ };
2241
+ function planInstall(projectRoot) {
2242
+ const claudeDir = path13.join(projectRoot, ".claude");
2243
+ const settingsPath = path13.join(claudeDir, "settings.json");
2244
+ const existed = fs11.existsSync(settingsPath);
2245
+ let existing = {};
2246
+ if (existed) {
2247
+ try {
2248
+ existing = JSON.parse(fs11.readFileSync(settingsPath, "utf-8"));
2249
+ } catch {
2250
+ existing = {};
2251
+ }
2252
+ }
2253
+ const mcpServers = existing.mcpServers ?? {};
2254
+ const alreadyInstalled = SERVER_NAME in mcpServers;
2255
+ const mergedSettings = {
2256
+ ...existing,
2257
+ mcpServers: {
2258
+ ...mcpServers,
2259
+ [SERVER_NAME]: SERVER_ENTRY
2260
+ }
2261
+ };
2262
+ return { settingsPath, existed, alreadyInstalled, mergedSettings };
2263
+ }
2264
+ function applyInstall(result) {
2265
+ const dir = path13.dirname(result.settingsPath);
2266
+ if (!fs11.existsSync(dir)) {
2267
+ fs11.mkdirSync(dir, { recursive: true });
2268
+ }
2269
+ fs11.writeFileSync(
2270
+ result.settingsPath,
2271
+ JSON.stringify(result.mergedSettings, null, 2) + "\n",
2272
+ "utf-8"
2273
+ );
2274
+ }
2275
+ function isInstalled(projectRoot) {
2276
+ const settingsPath = path13.join(projectRoot, ".claude", "settings.json");
2277
+ if (!fs11.existsSync(settingsPath)) return false;
2278
+ try {
2279
+ const settings = JSON.parse(fs11.readFileSync(settingsPath, "utf-8"));
2280
+ return SERVER_NAME in (settings.mcpServers ?? {});
2281
+ } catch {
2282
+ return false;
2283
+ }
2284
+ }
2285
+
2286
+ // src/commands/mcp.ts
2287
+ async function mcpCommand(options) {
2288
+ const projectRoot = options.path ? path17.resolve(options.path) : process.cwd();
2289
+ if (options.install) {
2290
+ await runInstall(projectRoot);
2291
+ return;
2292
+ }
2293
+ if (options.port) {
2294
+ process.stderr.write(
2295
+ chalk4.yellow(
2296
+ `HTTP transport (--port) is coming in a future release.
2297
+ Starting stdio server instead.
2298
+ `
2299
+ )
2300
+ );
2301
+ }
2302
+ if (!isInstalled(projectRoot)) {
2303
+ process.stderr.write(
2304
+ chalk4.dim(
2305
+ `Tip: run "claudectx mcp --install" to add this server to .claude/settings.json
2306
+ `
2307
+ )
2308
+ );
2309
+ }
2310
+ const { startMcpServer: startMcpServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
2311
+ await startMcpServer2();
2312
+ }
2313
+ async function runInstall(projectRoot) {
2314
+ const result = planInstall(projectRoot);
2315
+ if (result.alreadyInstalled) {
2316
+ logger.success(
2317
+ `claudectx MCP server is already registered in ${chalk4.cyan(result.settingsPath)}`
2318
+ );
2319
+ return;
2320
+ }
2321
+ logger.info(`Adding claudectx MCP server to ${chalk4.cyan(result.settingsPath)} ...`);
2322
+ applyInstall(result);
2323
+ logger.success("MCP server installed!");
2324
+ console.log("");
2325
+ console.log(chalk4.dim(" Claude Code will pick it up on next restart."));
2326
+ console.log(chalk4.dim(" Tools available to Claude:"));
2327
+ console.log(chalk4.dim(" \u2022 smart_read \u2014 read a symbol instead of a whole file"));
2328
+ console.log(chalk4.dim(" \u2022 search_symbols \u2014 search for symbols by name"));
2329
+ console.log(chalk4.dim(" \u2022 index_project \u2014 build the symbol index"));
2330
+ console.log("");
2331
+ console.log(chalk4.dim(` Settings file: ${result.settingsPath}`));
2332
+ }
2333
+
2334
+ // src/commands/compress.ts
2335
+ init_esm_shims();
2336
+ init_session_reader();
2337
+ import * as path19 from "path";
2338
+ import * as fs16 from "fs";
2339
+
2340
+ // src/compressor/session-parser.ts
2341
+ init_esm_shims();
2342
+ import * as fs14 from "fs";
2343
+ function extractText(content) {
2344
+ if (!content) return "";
2345
+ if (typeof content === "string") return content;
2346
+ return content.filter((b) => b.type === "text" && b.text).map((b) => b.text).join("\n").trim();
2347
+ }
2348
+ function extractToolCalls(content) {
2349
+ if (!content || typeof content === "string") return [];
2350
+ return content.filter((b) => b.type === "tool_use" && b.name).map((b) => ({ tool: b.name, input: b.input ?? {} }));
2351
+ }
2352
+ function parseSessionFile(sessionFilePath) {
2353
+ if (!fs14.existsSync(sessionFilePath)) return null;
2354
+ let content;
2355
+ try {
2356
+ content = fs14.readFileSync(sessionFilePath, "utf-8");
2357
+ } catch {
2358
+ return null;
2359
+ }
2360
+ const lines = content.trim().split("\n").filter(Boolean);
2361
+ const turns = [];
2362
+ const totalUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
2363
+ for (const line of lines) {
2364
+ try {
2365
+ const entry = JSON.parse(line);
2366
+ const msg = entry.message;
2367
+ if (!msg) continue;
2368
+ const role = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : null;
2369
+ if (!role) continue;
2370
+ const usage = msg.usage ?? entry.usage;
2371
+ if (usage) {
2372
+ totalUsage.inputTokens += usage.input_tokens ?? 0;
2373
+ totalUsage.outputTokens += usage.output_tokens ?? 0;
2374
+ totalUsage.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
2375
+ }
2376
+ turns.push({
2377
+ role,
2378
+ text: extractText(msg.content),
2379
+ toolCalls: extractToolCalls(msg.content),
2380
+ usage: usage ? { inputTokens: usage.input_tokens ?? 0, outputTokens: usage.output_tokens ?? 0 } : void 0
2381
+ });
2382
+ } catch {
2383
+ }
2384
+ }
2385
+ const filesRead = /* @__PURE__ */ new Set();
2386
+ const filesEdited = /* @__PURE__ */ new Set();
2387
+ const filesCreated = /* @__PURE__ */ new Set();
2388
+ const commandsRun = [];
2389
+ for (const turn of turns) {
2390
+ for (const tc of turn.toolCalls) {
2391
+ const fp = tc.input.file_path ?? tc.input.path ?? tc.input.file;
2392
+ switch (tc.tool) {
2393
+ case "Read":
2394
+ if (fp) filesRead.add(fp);
2395
+ break;
2396
+ case "Edit":
2397
+ case "MultiEdit":
2398
+ if (fp) filesEdited.add(fp);
2399
+ break;
2400
+ case "Write":
2401
+ if (fp) filesCreated.add(fp);
2402
+ break;
2403
+ case "Bash": {
2404
+ const cmd = tc.input.command;
2405
+ if (cmd) commandsRun.push(cmd.slice(0, 120));
2406
+ break;
2407
+ }
2408
+ }
2409
+ }
2410
+ }
2411
+ const sessionId = sessionFilePath.replace(/^.*[\\/]/, "").replace(".jsonl", "");
2412
+ return {
2413
+ sessionId,
2414
+ filePath: sessionFilePath,
2415
+ turns,
2416
+ totalUsage,
2417
+ filesRead: [...filesRead],
2418
+ filesEdited: [...filesEdited],
2419
+ filesCreated: [...filesCreated],
2420
+ commandsRun,
2421
+ turnCount: turns.filter((t) => t.role === "user").length
2422
+ };
2423
+ }
2424
+ function buildConversationText(session, maxChars = 2e4) {
2425
+ const parts = [];
2426
+ const relevantTurns = session.turns.slice(-20);
2427
+ for (const turn of relevantTurns) {
2428
+ if (!turn.text && turn.toolCalls.length === 0) continue;
2429
+ const label = turn.role === "user" ? "USER" : "ASSISTANT";
2430
+ const text = turn.text ? turn.text.slice(0, 800) : "";
2431
+ const tools = turn.toolCalls.length > 0 ? `[tools: ${turn.toolCalls.map((t) => t.tool).join(", ")}]` : "";
2432
+ parts.push(`${label}: ${text} ${tools}`.trim());
2433
+ }
2434
+ const body = parts.join("\n\n");
2435
+ return body.length > maxChars ? body.slice(0, maxChars) + "\n\u2026(truncated)" : body;
2436
+ }
2437
+
2438
+ // src/compressor/summarizer.ts
2439
+ init_esm_shims();
2440
+
2441
+ // src/shared/config.ts
2442
+ init_esm_shims();
2443
+ import Conf from "conf";
2444
+ var conf = new Conf({
2445
+ projectName: "claudectx",
2446
+ defaults: {
2447
+ defaultModel: "claude-sonnet-4-6",
2448
+ maxMemoryTokens: 3e3,
2449
+ maxClaudeMdTokens: 2e3,
2450
+ watchPollIntervalMs: 2e3
2451
+ }
2452
+ });
2453
+ function getApiKey() {
2454
+ return process.env.ANTHROPIC_API_KEY || conf.get("anthropicApiKey");
2455
+ }
2456
+
2457
+ // src/compressor/summarizer.ts
2458
+ init_models();
2459
+ var SUMMARY_MODEL = "claude-haiku-4-5-20251001";
2460
+ var SUMMARY_MAX_TOKENS = 300;
2461
+ var SYSTEM_PROMPT = `You are a session-compressor for Claude Code.
2462
+ Your job is to produce a concise MEMORY.md entry (max 200 words) for a coding session.
2463
+
2464
+ Focus on:
2465
+ - What was built or fixed (specific function/file names)
2466
+ - Key decisions or patterns established
2467
+ - Any gotchas or critical context for future sessions
2468
+
2469
+ Output ONLY the entry body \u2014 no frontmatter, no headings, no preamble.
2470
+ Use bullet points. Be terse. Prioritise facts over narrative.`;
2471
+ async function summariseWithAI(conversationText, apiKey) {
2472
+ const key = apiKey ?? getApiKey();
2473
+ if (!key) {
2474
+ throw new Error("No API key available");
2475
+ }
2476
+ const { default: Anthropic } = await import("@anthropic-ai/sdk");
2477
+ const client = new Anthropic({ apiKey: key });
2478
+ const response = await client.messages.create({
2479
+ model: SUMMARY_MODEL,
2480
+ max_tokens: SUMMARY_MAX_TOKENS,
2481
+ system: SYSTEM_PROMPT,
2482
+ messages: [
2483
+ {
2484
+ role: "user",
2485
+ content: `Summarise this Claude Code session:
2486
+
2487
+ ${conversationText}`
2488
+ }
2489
+ ]
2490
+ });
2491
+ const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim() || "(no summary generated)";
2492
+ return {
2493
+ text,
2494
+ method: "ai",
2495
+ model: SUMMARY_MODEL,
2496
+ inputTokens: response.usage.input_tokens
2497
+ };
2498
+ }
2499
+ function summariseHeuristically(session) {
2500
+ const lines = [];
2501
+ const firstUser = session.turns.find((t) => t.role === "user" && t.text);
2502
+ if (firstUser?.text) {
2503
+ const brief = firstUser.text.split("\n")[0].slice(0, 200);
2504
+ lines.push(`- **Task:** ${brief}`);
2505
+ }
2506
+ if (session.filesCreated.length > 0) {
2507
+ lines.push(`- **Created:** ${session.filesCreated.map(shortPath2).join(", ")}`);
2508
+ }
2509
+ if (session.filesEdited.length > 0) {
2510
+ const edited = session.filesEdited.slice(0, 8).map(shortPath2).join(", ");
2511
+ lines.push(`- **Edited:** ${edited}${session.filesEdited.length > 8 ? " \u2026" : ""}`);
2512
+ }
2513
+ if (session.filesRead.length > 0) {
2514
+ lines.push(`- **Read ${session.filesRead.length} file(s)**`);
2515
+ }
2516
+ const notable = session.commandsRun.filter((c) => !c.startsWith("echo") && !c.startsWith("cat")).slice(0, 3);
2517
+ if (notable.length > 0) {
2518
+ lines.push(`- **Commands:** ${notable.map((c) => `\`${c.slice(0, 60)}\``).join(", ")}`);
2519
+ }
2520
+ const totalIn = session.totalUsage.inputTokens;
2521
+ const totalOut = session.totalUsage.outputTokens;
2522
+ const cost = calcCost(totalIn, totalOut);
2523
+ lines.push(
2524
+ `- **Stats:** ${session.turnCount} requests, ${fmt(totalIn)}\u2193 / ${fmt(totalOut)}\u2191 tokens, ~$${cost}`
2525
+ );
2526
+ return {
2527
+ text: lines.join("\n") || "- (No session content extracted)",
2528
+ method: "heuristic"
2529
+ };
2530
+ }
2531
+ async function summariseSession(session, conversationText, apiKey) {
2532
+ const key = apiKey ?? getApiKey();
2533
+ if (key) {
2534
+ try {
2535
+ return await summariseWithAI(conversationText, key);
2536
+ } catch {
2537
+ }
2538
+ }
2539
+ return summariseHeuristically(session);
2540
+ }
2541
+ function shortPath2(p) {
2542
+ const parts = p.split("/");
2543
+ return parts.slice(-2).join("/");
2544
+ }
2545
+ function fmt(n) {
2546
+ return n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
2547
+ }
2548
+ function calcCost(inputTokens, outputTokens) {
2549
+ const p = MODEL_PRICING["claude-sonnet-4-6"];
2550
+ const cost = inputTokens / 1e6 * p.inputPerMillion + outputTokens / 1e6 * p.outputPerMillion;
2551
+ return cost.toFixed(3);
2552
+ }
2553
+
2554
+ // src/compressor/memory-writer.ts
2555
+ init_esm_shims();
2556
+ import * as fs15 from "fs";
2557
+ import * as path18 from "path";
2558
+ function parseMemoryFile(filePath) {
2559
+ if (!fs15.existsSync(filePath)) {
2560
+ return { preamble: "", entries: [] };
2561
+ }
2562
+ const content = fs15.readFileSync(filePath, "utf-8");
2563
+ const markerRegex = /<!-- claudectx-entry: (\d{4}-\d{2}-\d{2}) \| session: ([a-z0-9-]+) -->/g;
2564
+ const indices = [];
2565
+ let match;
2566
+ while (true) {
2567
+ match = markerRegex.exec(content);
2568
+ if (!match) break;
2569
+ indices.push(match.index);
2570
+ }
2571
+ if (indices.length === 0) {
2572
+ return { preamble: content, entries: [] };
2573
+ }
2574
+ const preamble = content.slice(0, indices[0]);
2575
+ const entries = [];
2576
+ for (let i = 0; i < indices.length; i++) {
2577
+ const start = indices[i];
2578
+ const end = i + 1 < indices.length ? indices[i + 1] : content.length;
2579
+ const block = content.slice(start, end).trim();
2580
+ const headerMatch = block.match(
2581
+ /<!-- claudectx-entry: (\d{4}-\d{2}-\d{2}) \| session: ([a-z0-9-]+) -->/
2582
+ );
2583
+ if (!headerMatch) continue;
2584
+ entries.push({
2585
+ date: headerMatch[1],
2586
+ sessionId: headerMatch[2],
2587
+ raw: block
2588
+ });
2589
+ }
2590
+ return { preamble, entries };
2591
+ }
2592
+ function buildEntryBlock(sessionId, summaryText, date = /* @__PURE__ */ new Date()) {
2593
+ const dateStr = date.toISOString().slice(0, 10);
2594
+ const shortId = sessionId.slice(0, 8);
2595
+ const heading = `### [${dateStr}] Session ${shortId}\u2026`;
2596
+ return [
2597
+ `<!-- claudectx-entry: ${dateStr} | session: ${sessionId} -->`,
2598
+ heading,
2599
+ "",
2600
+ summaryText.trim(),
2601
+ "",
2602
+ "---"
2603
+ ].join("\n");
2604
+ }
2605
+ function appendEntry(memoryFilePath, sessionId, summaryText, date = /* @__PURE__ */ new Date()) {
2606
+ const { preamble, entries } = parseMemoryFile(memoryFilePath);
2607
+ if (entries.some((e) => e.sessionId === sessionId)) {
2608
+ throw new Error(`Session ${sessionId.slice(0, 8)} is already in MEMORY.md`);
2609
+ }
2610
+ const newBlock = buildEntryBlock(sessionId, summaryText, date);
2611
+ const allBlocks = [...entries.map((e) => e.raw), newBlock];
2612
+ const newContent = (preamble.trimEnd() ? preamble.trimEnd() + "\n\n" : "") + allBlocks.join("\n\n") + "\n";
2613
+ const dir = path18.dirname(memoryFilePath);
2614
+ if (!fs15.existsSync(dir)) {
2615
+ fs15.mkdirSync(dir, { recursive: true });
2616
+ }
2617
+ fs15.writeFileSync(memoryFilePath, newContent, "utf-8");
2618
+ return newContent;
2619
+ }
2620
+ function pruneOldEntries(memoryFilePath, days) {
2621
+ if (!fs15.existsSync(memoryFilePath)) {
2622
+ return { removed: 0, kept: 0, removedEntries: [] };
2623
+ }
2624
+ const { preamble, entries } = parseMemoryFile(memoryFilePath);
2625
+ const cutoff = /* @__PURE__ */ new Date();
2626
+ cutoff.setDate(cutoff.getDate() - days);
2627
+ const cutoffStr = cutoff.toISOString().slice(0, 10);
2628
+ const kept = entries.filter((e) => e.date >= cutoffStr);
2629
+ const removed = entries.filter((e) => e.date < cutoffStr);
2630
+ if (removed.length === 0) {
2631
+ return { removed: 0, kept: kept.length, removedEntries: [] };
2632
+ }
2633
+ const newContent = (preamble.trimEnd() ? preamble.trimEnd() + "\n\n" : "") + kept.map((e) => e.raw).join("\n\n") + (kept.length > 0 ? "\n" : "");
2634
+ fs15.writeFileSync(memoryFilePath, newContent, "utf-8");
2635
+ return { removed: removed.length, kept: kept.length, removedEntries: removed };
2636
+ }
2637
+ function isAlreadyCompressed(memoryFilePath, sessionId) {
2638
+ const { entries } = parseMemoryFile(memoryFilePath);
2639
+ return entries.some((e) => e.sessionId === sessionId);
2640
+ }
2641
+
2642
+ // src/commands/compress.ts
2643
+ async function compressCommand(options) {
2644
+ const chalk5 = (await import("chalk")).default;
2645
+ const projectRoot = options.path ? path19.resolve(options.path) : process.cwd();
2646
+ const memoryFilePath = path19.join(projectRoot, "MEMORY.md");
2647
+ const sessionFiles = listSessionFiles();
2648
+ if (sessionFiles.length === 0) {
2649
+ process.stdout.write(chalk5.red("No Claude Code sessions found.\n"));
2650
+ process.stdout.write(chalk5.dim("Sessions are stored in ~/.claude/projects/\n"));
2651
+ process.exitCode = 1;
2652
+ return;
2653
+ }
2654
+ let targetFile;
2655
+ if (options.session) {
2656
+ const match = sessionFiles.find(
2657
+ (f) => f.sessionId === options.session || f.sessionId.startsWith(options.session)
2658
+ );
2659
+ if (!match) {
2660
+ process.stdout.write(chalk5.red(`Session not found: ${options.session}
2661
+ `));
2662
+ process.stdout.write(chalk5.dim(`Available: ${sessionFiles.slice(0, 5).map((f) => f.sessionId).join(", ")}
2663
+ `));
2664
+ process.exitCode = 1;
2665
+ return;
2666
+ }
2667
+ targetFile = match.filePath;
2668
+ } else {
2669
+ targetFile = sessionFiles[0].filePath;
2670
+ }
2671
+ const sessionId = path19.basename(targetFile, ".jsonl");
2672
+ if (isAlreadyCompressed(memoryFilePath, sessionId)) {
2673
+ if (!options.auto) {
2674
+ process.stdout.write(chalk5.yellow(`Session ${sessionId.slice(0, 8)}\u2026 is already in MEMORY.md \u2014 skipping.
2675
+ `));
2676
+ }
2677
+ return;
2678
+ }
2679
+ const parsed = parseSessionFile(targetFile);
2680
+ if (!parsed) {
2681
+ process.stdout.write(chalk5.red(`Failed to parse session file: ${targetFile}
2682
+ `));
2683
+ process.exitCode = 1;
2684
+ return;
2685
+ }
2686
+ if (!options.auto) {
2687
+ process.stdout.write(
2688
+ chalk5.cyan(`Compressing session ${chalk5.bold(sessionId.slice(0, 8))}\u2026 `) + chalk5.dim(`(${parsed.turnCount} turns, ${parsed.filesEdited.length} files edited)
2689
+ `)
2690
+ );
2691
+ }
2692
+ const conversationText = buildConversationText(parsed);
2693
+ const apiKey = options.apiKey ?? getApiKey();
2694
+ let spinner = null;
2695
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
2696
+ let frameIdx = 0;
2697
+ if (!options.auto && apiKey) {
2698
+ process.stdout.write(chalk5.dim("Summarizing with AI\u2026 "));
2699
+ spinner = setInterval(() => {
2700
+ process.stdout.write(`\r${chalk5.dim("Summarizing with AI\u2026 ")}${frames[frameIdx++ % frames.length]}`);
2701
+ }, 80);
2702
+ }
2703
+ const result = await summariseSession(parsed, conversationText, apiKey ?? void 0);
2704
+ if (spinner) {
2705
+ clearInterval(spinner);
2706
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
2707
+ }
2708
+ appendEntry(memoryFilePath, sessionId, result.text);
2709
+ if (!options.auto) {
2710
+ const methodLabel = result.method === "ai" ? chalk5.green(`AI (${result.model}, ${result.inputTokens} tokens)`) : chalk5.yellow("heuristic (no API key)");
2711
+ process.stdout.write(chalk5.green("\u2713") + ` Appended to ${chalk5.bold(memoryFilePath)} via ${methodLabel}
2712
+ `);
2713
+ process.stdout.write("\n" + chalk5.dim("\u2500".repeat(60)) + "\n");
2714
+ process.stdout.write(result.text + "\n");
2715
+ process.stdout.write(chalk5.dim("\u2500".repeat(60)) + "\n");
2716
+ }
2717
+ if (options.prune) {
2718
+ const days = parseInt(options.days ?? "30", 10);
2719
+ if (!fs16.existsSync(memoryFilePath)) return;
2720
+ const pruned = pruneOldEntries(memoryFilePath, days);
2721
+ if (pruned.removed > 0 && !options.auto) {
2722
+ process.stdout.write(
2723
+ chalk5.dim(`Pruned ${pruned.removed} entr${pruned.removed === 1 ? "y" : "ies"} older than ${days} days.
2724
+ `)
2725
+ );
2726
+ }
2727
+ }
2728
+ }
2729
+
2730
+ // src/commands/report.ts
2731
+ init_esm_shims();
2732
+
2733
+ // src/reporter/usage-aggregator.ts
2734
+ init_esm_shims();
2735
+ init_session_reader();
2736
+ init_session_store();
2737
+ init_models();
2738
+ function isoDate(d) {
2739
+ return d.toISOString().slice(0, 10);
2740
+ }
2741
+ function calcCost2(inputTokens, outputTokens, model) {
2742
+ const p = MODEL_PRICING[model];
2743
+ return inputTokens / 1e6 * p.inputPerMillion + outputTokens / 1e6 * p.outputPerMillion;
2744
+ }
2745
+ async function aggregateUsage(days, model = "claude-sonnet-4-6") {
2746
+ const now = /* @__PURE__ */ new Date();
2747
+ const cutoff = new Date(now);
2748
+ cutoff.setDate(cutoff.getDate() - days);
2749
+ const cutoffMs = cutoff.getTime();
2750
+ const sessionFiles = listSessionFiles().filter((f) => f.mtimeMs >= cutoffMs);
2751
+ const bucketMap = /* @__PURE__ */ new Map();
2752
+ for (let i = 0; i < days; i++) {
2753
+ const d = new Date(now);
2754
+ d.setDate(d.getDate() - i);
2755
+ const dateStr = isoDate(d);
2756
+ bucketMap.set(dateStr, {
2757
+ date: dateStr,
2758
+ sessions: 0,
2759
+ inputTokens: 0,
2760
+ outputTokens: 0,
2761
+ cacheReadTokens: 0,
2762
+ requests: 0,
2763
+ costUsd: 0
2764
+ });
2765
+ }
2766
+ let totalRequests = 0;
2767
+ let totalInput = 0;
2768
+ let totalOutput = 0;
2769
+ let totalCacheRead = 0;
2770
+ for (const sf of sessionFiles) {
2771
+ const dateStr = isoDate(new Date(sf.mtimeMs));
2772
+ const bucket = bucketMap.get(dateStr);
2773
+ if (!bucket) continue;
2774
+ const usage = readSessionUsage(sf.filePath);
2775
+ bucket.sessions++;
2776
+ bucket.inputTokens += usage.inputTokens;
2777
+ bucket.outputTokens += usage.outputTokens;
2778
+ bucket.cacheReadTokens += usage.cacheReadTokens;
2779
+ bucket.requests += usage.requestCount;
2780
+ bucket.costUsd += calcCost2(usage.inputTokens, usage.outputTokens, model);
2781
+ totalInput += usage.inputTokens;
2782
+ totalOutput += usage.outputTokens;
2783
+ totalCacheRead += usage.cacheReadTokens;
2784
+ totalRequests += usage.requestCount;
2785
+ }
2786
+ const fileEvents = readAllEvents().filter(
2787
+ (e) => new Date(e.timestamp).getTime() >= cutoffMs
2788
+ );
2789
+ const fileStats = aggregateStats(fileEvents);
2790
+ const topFiles = fileStats.slice(0, 10).map((s) => ({
2791
+ filePath: s.filePath,
2792
+ readCount: s.readCount
2793
+ }));
2794
+ const totalCost = calcCost2(totalInput, totalOutput, model);
2795
+ const cacheHitRate = totalInput > 0 ? Math.round(totalCacheRead / totalInput * 100) : 0;
2796
+ const byDay = [...bucketMap.values()].sort((a, b) => a.date.localeCompare(b.date));
2797
+ const uniqueSessions = new Set(sessionFiles.map((f) => f.sessionId)).size;
2798
+ return {
2799
+ periodDays: days,
2800
+ startDate: isoDate(cutoff),
2801
+ endDate: isoDate(now),
2802
+ totalSessions: uniqueSessions,
2803
+ totalRequests,
2804
+ totalInputTokens: totalInput,
2805
+ totalOutputTokens: totalOutput,
2806
+ totalCacheReadTokens: totalCacheRead,
2807
+ cacheHitRate,
2808
+ totalCostUsd: totalCost,
2809
+ avgCostPerSession: uniqueSessions > 0 ? totalCost / uniqueSessions : 0,
2810
+ avgTokensPerRequest: totalRequests > 0 ? Math.round(totalInput / totalRequests) : 0,
2811
+ byDay,
2812
+ topFiles,
2813
+ model,
2814
+ generatedAt: now.toISOString()
2815
+ };
2816
+ }
2817
+
2818
+ // src/reporter/formatter.ts
2819
+ init_esm_shims();
2820
+ function fmtNum2(n) {
2821
+ return n.toLocaleString();
2822
+ }
2823
+ function fmtCost2(usd) {
2824
+ if (usd < 0.01) return `$${usd.toFixed(4)}`;
2825
+ return `$${usd.toFixed(2)}`;
2826
+ }
2827
+ function fmtK(n) {
2828
+ return n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
2829
+ }
2830
+ function bar(value, max, width = 20) {
2831
+ if (max === 0) return " ".repeat(width);
2832
+ const filled = Math.round(value / max * width);
2833
+ return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
2834
+ }
2835
+ function shortPath3(p) {
2836
+ const parts = p.split("/");
2837
+ return parts.length > 3 ? "\u2026/" + parts.slice(-3).join("/") : p;
2838
+ }
2839
+ function formatText(data) {
2840
+ const lines = [];
2841
+ lines.push(
2842
+ `claudectx report \u2014 ${data.periodDays}-day summary (${data.startDate} \u2192 ${data.endDate})`
2843
+ );
2844
+ lines.push("\u2550".repeat(70));
2845
+ lines.push("");
2846
+ lines.push("TOTALS");
2847
+ lines.push("\u2500".repeat(40));
2848
+ lines.push(` Sessions: ${fmtNum2(data.totalSessions)}`);
2849
+ lines.push(` Requests: ${fmtNum2(data.totalRequests)}`);
2850
+ lines.push(` Input tokens: ${fmtNum2(data.totalInputTokens)}`);
2851
+ lines.push(` Output tokens: ${fmtNum2(data.totalOutputTokens)}`);
2852
+ lines.push(` Cache reads: ${fmtNum2(data.totalCacheReadTokens)} (${data.cacheHitRate}% hit rate)`);
2853
+ lines.push(` Total cost (est.): ${fmtCost2(data.totalCostUsd)}`);
2854
+ lines.push(` Avg cost/session: ${fmtCost2(data.avgCostPerSession)}`);
2855
+ lines.push(` Avg tokens/request: ${fmtNum2(data.avgTokensPerRequest)}`);
2856
+ lines.push(` Model: ${data.model}`);
2857
+ lines.push("");
2858
+ const activeDays = data.byDay.filter((d) => d.sessions > 0);
2859
+ if (activeDays.length > 0) {
2860
+ lines.push("DAILY USAGE");
2861
+ lines.push("\u2500".repeat(40));
2862
+ const maxTokens = Math.max(...activeDays.map((d) => d.inputTokens), 1);
2863
+ for (const day of data.byDay) {
2864
+ if (day.sessions === 0) continue;
2865
+ const b = bar(day.inputTokens, maxTokens, 18);
2866
+ lines.push(
2867
+ ` ${day.date} ${b} ${fmtK(day.inputTokens)} in ${fmtCost2(day.costUsd)} (${day.sessions} sess)`
2868
+ );
2869
+ }
2870
+ lines.push("");
2871
+ }
2872
+ if (data.topFiles.length > 0) {
2873
+ lines.push("TOP FILES READ");
2874
+ lines.push("\u2500".repeat(40));
2875
+ const maxReads = Math.max(...data.topFiles.map((f) => f.readCount), 1);
2876
+ for (let i = 0; i < data.topFiles.length; i++) {
2877
+ const f = data.topFiles[i];
2878
+ const b = bar(f.readCount, maxReads, 12);
2879
+ lines.push(` ${String(i + 1).padStart(2)}. ${b} \xD7${f.readCount} ${shortPath3(f.filePath)}`);
2880
+ }
2881
+ lines.push("");
2882
+ } else {
2883
+ lines.push(" No file-read data. Install hooks: claudectx optimize --hooks");
2884
+ lines.push("");
2885
+ }
2886
+ const tips = [];
2887
+ if (data.cacheHitRate < 30 && data.totalRequests > 5) {
2888
+ tips.push("Cache hit rate is low \u2014 run `claudectx optimize --cache` to fix dynamic content.");
2889
+ }
2890
+ if (data.avgTokensPerRequest > 1e4) {
2891
+ tips.push("High tokens/request \u2014 run `claudectx optimize --claudemd` to split your CLAUDE.md.");
2892
+ }
2893
+ if (data.topFiles.length === 0) {
2894
+ tips.push("Install hooks to track file reads: `claudectx optimize --hooks`.");
2895
+ }
2896
+ if (tips.length > 0) {
2897
+ lines.push("OPTIMISATION TIPS");
2898
+ lines.push("\u2500".repeat(40));
2899
+ tips.forEach((t) => lines.push(` \u26A1 ${t}`));
2900
+ lines.push("");
2901
+ }
2902
+ lines.push(`Generated at: ${data.generatedAt}`);
2903
+ return lines.join("\n");
2904
+ }
2905
+ function formatJSON(data) {
2906
+ return JSON.stringify(data, null, 2);
2907
+ }
2908
+ function formatMarkdown(data) {
2909
+ const lines = [];
2910
+ lines.push(`# claudectx Report`);
2911
+ lines.push("");
2912
+ lines.push(`**Period:** ${data.startDate} \u2192 ${data.endDate} (${data.periodDays} days)`);
2913
+ lines.push(`**Generated:** ${new Date(data.generatedAt).toLocaleString()}`);
2914
+ lines.push("");
2915
+ lines.push("## Summary");
2916
+ lines.push("");
2917
+ lines.push("| Metric | Value |");
2918
+ lines.push("|--------|-------|");
2919
+ lines.push(`| Sessions | ${fmtNum2(data.totalSessions)} |`);
2920
+ lines.push(`| Requests | ${fmtNum2(data.totalRequests)} |`);
2921
+ lines.push(`| Input tokens | ${fmtNum2(data.totalInputTokens)} |`);
2922
+ lines.push(`| Output tokens | ${fmtNum2(data.totalOutputTokens)} |`);
2923
+ lines.push(`| Cache hit rate | ${data.cacheHitRate}% |`);
2924
+ lines.push(`| Total cost (est.) | ${fmtCost2(data.totalCostUsd)} |`);
2925
+ lines.push(`| Avg cost/session | ${fmtCost2(data.avgCostPerSession)} |`);
2926
+ lines.push(`| Avg tokens/request | ${fmtNum2(data.avgTokensPerRequest)} |`);
2927
+ lines.push(`| Model | \`${data.model}\` |`);
2928
+ lines.push("");
2929
+ const activeDays = data.byDay.filter((d) => d.sessions > 0);
2930
+ if (activeDays.length > 0) {
2931
+ lines.push("## Daily Breakdown");
2932
+ lines.push("");
2933
+ lines.push("| Date | Sessions | Input tokens | Cost |");
2934
+ lines.push("|------|----------|-------------|------|");
2935
+ for (const day of activeDays) {
2936
+ lines.push(
2937
+ `| ${day.date} | ${day.sessions} | ${fmtK(day.inputTokens)} | ${fmtCost2(day.costUsd)} |`
2938
+ );
2939
+ }
2940
+ lines.push("");
2941
+ }
2942
+ if (data.topFiles.length > 0) {
2943
+ lines.push("## Top Files Read");
2944
+ lines.push("");
2945
+ lines.push("| # | File | Reads |");
2946
+ lines.push("|---|------|-------|");
2947
+ data.topFiles.forEach((f, i) => {
2948
+ lines.push(`| ${i + 1} | \`${shortPath3(f.filePath)}\` | ${f.readCount} |`);
2949
+ });
2950
+ lines.push("");
2951
+ }
2952
+ return lines.join("\n");
2953
+ }
2954
+ function format(data, mode) {
2955
+ switch (mode) {
2956
+ case "json":
2957
+ return formatJSON(data);
2958
+ case "markdown":
2959
+ return formatMarkdown(data);
2960
+ default:
2961
+ return formatText(data);
2962
+ }
2963
+ }
2964
+
2965
+ // src/commands/report.ts
2966
+ var MODEL_ALIASES2 = {
2967
+ haiku: "claude-haiku-4-5-20251001",
2968
+ sonnet: "claude-sonnet-4-6",
2969
+ opus: "claude-opus-4-6",
2970
+ "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
2971
+ "claude-sonnet-4-6": "claude-sonnet-4-6",
2972
+ "claude-opus-4-6": "claude-opus-4-6"
2973
+ };
2974
+ async function reportCommand(options) {
2975
+ const days = Math.max(1, parseInt(options.days ?? "7", 10));
2976
+ const modelAlias = options.model ?? "sonnet";
2977
+ const model = MODEL_ALIASES2[modelAlias] ?? "claude-sonnet-4-6";
2978
+ const mode = options.json ? "json" : options.markdown ? "markdown" : "text";
2979
+ const data = await aggregateUsage(days, model);
2980
+ const output = format(data, mode);
2981
+ process.stdout.write(output + "\n");
2982
+ }
2983
+
2984
+ // src/index.ts
2985
+ var VERSION = "1.0.0";
2986
+ var DESCRIPTION = "Reduce Claude Code token usage by up to 80%. Context analyzer, auto-optimizer, live dashboard, and smart MCP tools.";
2987
+ var program = new Command();
2988
+ program.name("claudectx").description(DESCRIPTION).version(VERSION);
2989
+ program.command("analyze").alias("a").description("Analyze token usage in the current Claude Code project").option("-p, --path <path>", "Path to project directory (default: cwd)").option("-j, --json", "Output raw JSON (for scripting)").option("-m, --model <model>", "Claude model to estimate costs for (haiku|sonnet|opus)", "sonnet").option("-w, --watch", "Re-run analysis on CLAUDE.md / MEMORY.md changes").action(async (options) => {
2990
+ await analyzeCommand(options);
2991
+ });
2992
+ program.command("optimize").alias("o").description("Auto-fix token waste issues in CLAUDE.md, .claudeignore, and hooks").option("-p, --path <path>", "Path to project directory (default: cwd)").option("--apply", "Apply all fixes without prompting").option("--dry-run", "Preview changes without applying").option("--claudemd", "Only optimize CLAUDE.md (split into @files)").option("--ignorefile", "Only generate .claudeignore").option("--cache", "Only fix cache-busting content").option("--hooks", "Only install session hooks").option("--api-key <key>", "Anthropic API key (for AI-powered CLAUDE.md rewriting)").action(async (options) => {
2993
+ await optimizeCommand(options);
2994
+ });
2995
+ program.command("watch").alias("w").description("Live token-usage dashboard \u2014 tracks files read and session cost in real time").option("--session <id>", "Watch a specific session ID (default: most recent)").option("-m, --model <model>", "Model for cost estimates (haiku|sonnet|opus)", "sonnet").option("--log-stdin", "Read hook JSON from stdin and log the file path (called by Claude Code hook)").option("--clear", "Clear the session file-read log and exit").action(async (options) => {
2996
+ await watchCommand(options);
2997
+ });
2998
+ program.command("mcp").description("Start the smart MCP server \u2014 symbol-level file reading for Claude Code").option("-p, --path <path>", "Project root (default: cwd)").option("--port <port>", "HTTP transport port (stdio is default; HTTP coming soon)").option("--install", "Add server to .claude/settings.json and exit").action(async (options) => {
2999
+ await mcpCommand(options);
3000
+ });
3001
+ program.command("compress").alias("c").description("Compress a Claude Code session into a compact MEMORY.md entry").option("-p, --path <path>", "Project directory (default: cwd)").option("--session <id>", "Compress specific session ID (default: most recent)").option("--auto", "Non-interactive mode (for hooks)").option("--prune", "Also prune old MEMORY.md entries").option("--days <n>", "Days threshold for pruning (with --prune)", "30").option("--api-key <key>", "Anthropic API key for AI-powered summarization").action(async (options) => {
3002
+ await compressCommand(options);
3003
+ });
3004
+ program.command("report").alias("r").description("Show token usage analytics for the last N days").option("-p, --path <path>", "Project directory (default: cwd)").option("--days <n>", "Number of days to include", "7").option("--json", "Machine-readable JSON output").option("--markdown", "GitHub-flavoured Markdown output").option("-m, --model <model>", "Claude model for cost estimates (haiku|sonnet|opus)", "sonnet").action(async (options) => {
3005
+ await reportCommand(options);
3006
+ });
3007
+ program.parse();
3008
+ //# sourceMappingURL=index.mjs.map