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