devrage 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/cli.js +846 -0
  2. package/dist/cli.js.map +7 -0
  3. package/dist/lib/adapters/amp.d.ts +3 -0
  4. package/dist/lib/adapters/amp.d.ts.map +1 -0
  5. package/dist/lib/adapters/amp.js +74 -0
  6. package/dist/lib/adapters/amp.js.map +1 -0
  7. package/dist/lib/adapters/claude.d.ts +3 -0
  8. package/dist/lib/adapters/claude.d.ts.map +1 -0
  9. package/dist/lib/adapters/claude.js +137 -0
  10. package/dist/lib/adapters/claude.js.map +1 -0
  11. package/dist/lib/adapters/cline.d.ts +3 -0
  12. package/dist/lib/adapters/cline.d.ts.map +1 -0
  13. package/dist/lib/adapters/cline.js +122 -0
  14. package/dist/lib/adapters/cline.js.map +1 -0
  15. package/dist/lib/adapters/codex.d.ts +3 -0
  16. package/dist/lib/adapters/codex.d.ts.map +1 -0
  17. package/dist/lib/adapters/codex.js +99 -0
  18. package/dist/lib/adapters/codex.js.map +1 -0
  19. package/dist/lib/adapters/index.d.ts +17 -0
  20. package/dist/lib/adapters/index.d.ts.map +1 -0
  21. package/dist/lib/adapters/index.js +25 -0
  22. package/dist/lib/adapters/index.js.map +1 -0
  23. package/dist/lib/adapters/opencode.d.ts +3 -0
  24. package/dist/lib/adapters/opencode.d.ts.map +1 -0
  25. package/dist/lib/adapters/opencode.js +86 -0
  26. package/dist/lib/adapters/opencode.js.map +1 -0
  27. package/dist/lib/adapters/zed.d.ts +3 -0
  28. package/dist/lib/adapters/zed.d.ts.map +1 -0
  29. package/dist/lib/adapters/zed.js +156 -0
  30. package/dist/lib/adapters/zed.js.map +1 -0
  31. package/dist/lib/detector/index.d.ts +32 -0
  32. package/dist/lib/detector/index.d.ts.map +1 -0
  33. package/dist/lib/detector/index.js +224 -0
  34. package/dist/lib/detector/index.js.map +1 -0
  35. package/dist/lib/index.d.ts +3 -0
  36. package/dist/lib/index.d.ts.map +1 -0
  37. package/dist/lib/index.js +3 -0
  38. package/dist/lib/index.js.map +1 -0
  39. package/package.json +40 -0
package/dist/cli.js ADDED
@@ -0,0 +1,846 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/adapters/amp.ts
4
+ import { readdir, readFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ function getAmpThreadsDir() {
8
+ return join(
9
+ process.env["XDG_DATA_HOME"] ?? join(homedir(), ".local", "share"),
10
+ "amp",
11
+ "threads"
12
+ );
13
+ }
14
+ function ampAdapter() {
15
+ return {
16
+ name: "amp",
17
+ async *messages(options) {
18
+ const threadsDir = getAmpThreadsDir();
19
+ let files;
20
+ try {
21
+ files = await readdir(threadsDir);
22
+ } catch {
23
+ return;
24
+ }
25
+ const jsonFiles = files.filter((f) => f.endsWith(".json"));
26
+ for (const file of jsonFiles) {
27
+ const filePath = join(threadsDir, file);
28
+ const threadId = file.replace(".json", "");
29
+ try {
30
+ const raw = await readFile(filePath, "utf-8");
31
+ const thread = JSON.parse(raw);
32
+ if (!thread.messages || !Array.isArray(thread.messages)) continue;
33
+ for (const msg of thread.messages) {
34
+ if (msg.role !== "user") continue;
35
+ const text = extractText(msg.content);
36
+ if (!text) continue;
37
+ const timestamp = msg.timestamp ?? msg.createdAt ?? void 0;
38
+ if (options?.since && timestamp) {
39
+ const ts = new Date(timestamp);
40
+ if (ts < options.since) continue;
41
+ }
42
+ yield {
43
+ text,
44
+ timestamp,
45
+ session: threadId
46
+ };
47
+ }
48
+ } catch {
49
+ }
50
+ }
51
+ }
52
+ };
53
+ }
54
+ function extractText(content) {
55
+ if (typeof content === "string") return content;
56
+ if (Array.isArray(content)) {
57
+ const parts = content.filter(
58
+ (p) => typeof p === "object" && p !== null && typeof p.text === "string"
59
+ ).map((p) => p.text);
60
+ return parts.length > 0 ? parts.join(" ") : null;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ // src/adapters/claude.ts
66
+ import { createReadStream } from "node:fs";
67
+ import { readdir as readdir2, stat } from "node:fs/promises";
68
+ import { createInterface } from "node:readline";
69
+ import { homedir as homedir2 } from "node:os";
70
+ import { join as join2 } from "node:path";
71
+ var CLAUDE_DIR = join2(homedir2(), ".claude", "projects");
72
+ function claudeAdapter() {
73
+ return {
74
+ name: "claude",
75
+ async *messages(options) {
76
+ const projectsDir = CLAUDE_DIR;
77
+ let projectDirs;
78
+ try {
79
+ projectDirs = await readdir2(projectsDir);
80
+ } catch {
81
+ return;
82
+ }
83
+ for (const projectDir of projectDirs) {
84
+ const projectPath = join2(projectsDir, projectDir);
85
+ const projectStat = await stat(projectPath);
86
+ if (!projectStat.isDirectory()) continue;
87
+ const entries = await readdir2(projectPath);
88
+ const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl"));
89
+ for (const file of jsonlFiles) {
90
+ const filePath = join2(projectPath, file);
91
+ const session = file.replace(".jsonl", "");
92
+ yield* parseClaudeJsonl(filePath, {
93
+ session,
94
+ project: projectDir,
95
+ since: options?.since
96
+ });
97
+ }
98
+ const subdirs = entries.filter((f) => !f.includes("."));
99
+ for (const subdir of subdirs) {
100
+ const subagentsDir = join2(projectPath, subdir, "subagents");
101
+ try {
102
+ const subFiles = await readdir2(subagentsDir);
103
+ const subJsonl = subFiles.filter((f) => f.endsWith(".jsonl"));
104
+ for (const file of subJsonl) {
105
+ yield* parseClaudeJsonl(join2(subagentsDir, file), {
106
+ session: `${subdir}/${file.replace(".jsonl", "")}`,
107
+ project: projectDir,
108
+ since: options?.since
109
+ });
110
+ }
111
+ } catch {
112
+ }
113
+ }
114
+ }
115
+ }
116
+ };
117
+ }
118
+ async function* parseClaudeJsonl(filePath, context) {
119
+ const rl = createInterface({
120
+ input: createReadStream(filePath, { encoding: "utf-8" }),
121
+ crlfDelay: Infinity
122
+ });
123
+ for await (const line of rl) {
124
+ if (!line.trim()) continue;
125
+ try {
126
+ const entry = JSON.parse(line);
127
+ const text = extractUserText(entry);
128
+ if (!text) continue;
129
+ const timestamp = extractTimestamp(entry);
130
+ if (context.since && timestamp) {
131
+ const ts = new Date(timestamp);
132
+ if (ts < context.since) continue;
133
+ }
134
+ yield {
135
+ text,
136
+ timestamp: timestamp ?? void 0,
137
+ session: context.session,
138
+ project: context.project
139
+ };
140
+ } catch {
141
+ }
142
+ }
143
+ }
144
+ function extractUserText(entry) {
145
+ if (entry["type"] === "user") {
146
+ const message = entry["message"];
147
+ if (!message) return null;
148
+ return contentToString(message["content"]);
149
+ }
150
+ if (entry["type"] === "human") {
151
+ const message = entry["message"];
152
+ if (!message) return null;
153
+ return contentToString(message["content"]);
154
+ }
155
+ if (entry["role"] === "user") {
156
+ return contentToString(entry["content"]);
157
+ }
158
+ return null;
159
+ }
160
+ function contentToString(content) {
161
+ if (typeof content === "string") return content;
162
+ if (Array.isArray(content)) {
163
+ const parts = content.filter(
164
+ (p) => typeof p === "object" && p !== null && p.type === "text"
165
+ ).map((p) => p.text);
166
+ return parts.length > 0 ? parts.join(" ") : null;
167
+ }
168
+ return null;
169
+ }
170
+ function extractTimestamp(entry) {
171
+ if (typeof entry["timestamp"] === "string") return entry["timestamp"];
172
+ if (typeof entry["createdAt"] === "string") return entry["createdAt"];
173
+ return null;
174
+ }
175
+
176
+ // src/adapters/cline.ts
177
+ import { readdir as readdir3, readFile as readFile2, stat as stat2 } from "node:fs/promises";
178
+ import { existsSync } from "node:fs";
179
+ import { homedir as homedir3 } from "node:os";
180
+ import { join as join3 } from "node:path";
181
+ function getClineTaskDirs() {
182
+ const dirs = [];
183
+ const vscodePaths = getVSCodeGlobalStoragePaths();
184
+ const extensionIds = ["saoudrizwan.claude-dev", "rooveterinaryinc.roo-cline"];
185
+ for (const basePath of vscodePaths) {
186
+ for (const extId of extensionIds) {
187
+ const tasksDir = join3(basePath, extId, "tasks");
188
+ if (existsSync(tasksDir)) dirs.push(tasksDir);
189
+ }
190
+ }
191
+ const clineStandalone = join3(homedir3(), ".cline", "data", "tasks");
192
+ if (existsSync(clineStandalone)) dirs.push(clineStandalone);
193
+ return dirs;
194
+ }
195
+ function getVSCodeGlobalStoragePaths() {
196
+ const paths = [];
197
+ if (process.platform === "darwin") {
198
+ paths.push(
199
+ join3(homedir3(), "Library", "Application Support", "Code", "User", "globalStorage"),
200
+ join3(homedir3(), "Library", "Application Support", "Code - Insiders", "User", "globalStorage"),
201
+ join3(homedir3(), "Library", "Application Support", "Cursor", "User", "globalStorage")
202
+ );
203
+ } else if (process.platform === "linux") {
204
+ const configBase = process.env["XDG_CONFIG_HOME"] ?? join3(homedir3(), ".config");
205
+ paths.push(
206
+ join3(configBase, "Code", "User", "globalStorage"),
207
+ join3(configBase, "Code - Insiders", "User", "globalStorage"),
208
+ join3(configBase, "Cursor", "User", "globalStorage")
209
+ );
210
+ } else {
211
+ const appData = process.env["APPDATA"] ?? join3(homedir3(), "AppData", "Roaming");
212
+ paths.push(
213
+ join3(appData, "Code", "User", "globalStorage"),
214
+ join3(appData, "Code - Insiders", "User", "globalStorage"),
215
+ join3(appData, "Cursor", "User", "globalStorage")
216
+ );
217
+ }
218
+ return paths;
219
+ }
220
+ function clineAdapter() {
221
+ return {
222
+ name: "cline",
223
+ async *messages(options) {
224
+ const taskDirs = getClineTaskDirs();
225
+ for (const tasksDir of taskDirs) {
226
+ let taskIds;
227
+ try {
228
+ taskIds = await readdir3(tasksDir);
229
+ } catch {
230
+ continue;
231
+ }
232
+ for (const taskId of taskIds) {
233
+ const taskDir = join3(tasksDir, taskId);
234
+ const taskStat = await stat2(taskDir).catch(() => null);
235
+ if (!taskStat?.isDirectory()) continue;
236
+ const historyFile = join3(taskDir, "api_conversation_history.json");
237
+ try {
238
+ const raw = await readFile2(historyFile, "utf-8");
239
+ const messages = JSON.parse(raw);
240
+ if (!Array.isArray(messages)) continue;
241
+ for (const msg of messages) {
242
+ if (msg.role !== "user") continue;
243
+ const text = extractText2(msg.content);
244
+ if (!text) continue;
245
+ const timestamp = msg.ts ?? void 0;
246
+ if (options?.since && timestamp) {
247
+ const ts = new Date(timestamp);
248
+ if (ts < options.since) continue;
249
+ }
250
+ yield {
251
+ text,
252
+ session: taskId
253
+ };
254
+ }
255
+ } catch {
256
+ }
257
+ }
258
+ }
259
+ }
260
+ };
261
+ }
262
+ function extractText2(content) {
263
+ if (typeof content === "string") return content;
264
+ if (Array.isArray(content)) {
265
+ const parts = content.filter(
266
+ (p) => typeof p === "object" && p !== null && p.type === "text" && typeof p.text === "string"
267
+ ).map((p) => p.text);
268
+ return parts.length > 0 ? parts.join(" ") : null;
269
+ }
270
+ return null;
271
+ }
272
+
273
+ // src/adapters/codex.ts
274
+ import { createReadStream as createReadStream2 } from "node:fs";
275
+ import { readdir as readdir4, stat as stat3 } from "node:fs/promises";
276
+ import { createInterface as createInterface2 } from "node:readline";
277
+ import { homedir as homedir4 } from "node:os";
278
+ import { join as join4 } from "node:path";
279
+ var CODEX_SESSIONS_DIR = join4(homedir4(), ".codex", "sessions");
280
+ function codexAdapter() {
281
+ return {
282
+ name: "codex",
283
+ async *messages(options) {
284
+ yield* walkCodexSessions(CODEX_SESSIONS_DIR, options);
285
+ }
286
+ };
287
+ }
288
+ async function* walkCodexSessions(dir, options) {
289
+ let entries;
290
+ try {
291
+ entries = await readdir4(dir);
292
+ } catch {
293
+ return;
294
+ }
295
+ for (const entry of entries) {
296
+ const fullPath = join4(dir, entry);
297
+ const entryStat = await stat3(fullPath);
298
+ if (entryStat.isDirectory()) {
299
+ yield* walkCodexSessions(fullPath, options);
300
+ } else if (entry.endsWith(".jsonl")) {
301
+ const session = entry.replace(".jsonl", "");
302
+ yield* parseCodexJsonl(fullPath, { session, since: options?.since });
303
+ }
304
+ }
305
+ }
306
+ async function* parseCodexJsonl(filePath, context) {
307
+ const rl = createInterface2({
308
+ input: createReadStream2(filePath, { encoding: "utf-8" }),
309
+ crlfDelay: Infinity
310
+ });
311
+ for await (const line of rl) {
312
+ if (!line.trim()) continue;
313
+ try {
314
+ const entry = JSON.parse(line);
315
+ if (entry.type !== "response_item") continue;
316
+ const payload = entry.payload;
317
+ if (!payload || payload.role !== "user") continue;
318
+ const text = extractText3(payload.content);
319
+ if (!text) continue;
320
+ if (text.startsWith("<environment_context>")) continue;
321
+ if (text.startsWith("<permissions instructions>")) continue;
322
+ if (context.since && entry.timestamp) {
323
+ const ts = new Date(entry.timestamp);
324
+ if (ts < context.since) continue;
325
+ }
326
+ yield {
327
+ text,
328
+ timestamp: entry.timestamp,
329
+ session: context.session
330
+ };
331
+ } catch {
332
+ }
333
+ }
334
+ }
335
+ function extractText3(content) {
336
+ if (!Array.isArray(content)) return null;
337
+ const parts = content.filter(
338
+ (p) => typeof p === "object" && p !== null && p.type === "input_text" && typeof p.text === "string"
339
+ ).map((p) => p.text);
340
+ return parts.length > 0 ? parts.join(" ") : null;
341
+ }
342
+
343
+ // src/adapters/opencode.ts
344
+ import { existsSync as existsSync2 } from "node:fs";
345
+ import { homedir as homedir5 } from "node:os";
346
+ import { join as join5 } from "node:path";
347
+ function getOpencodeDatabasePath() {
348
+ const xdgPath = join5(
349
+ process.env["XDG_DATA_HOME"] ?? join5(homedir5(), ".local", "share"),
350
+ "opencode",
351
+ "opencode.db"
352
+ );
353
+ if (existsSync2(xdgPath)) return xdgPath;
354
+ if (process.platform === "darwin") {
355
+ const macPath = join5(
356
+ homedir5(),
357
+ "Library",
358
+ "Application Support",
359
+ "opencode",
360
+ "opencode.db"
361
+ );
362
+ if (existsSync2(macPath)) return macPath;
363
+ }
364
+ return null;
365
+ }
366
+ function opencodeAdapter() {
367
+ return {
368
+ name: "opencode",
369
+ async *messages(options) {
370
+ const dbPath = getOpencodeDatabasePath();
371
+ if (!dbPath) return;
372
+ let db;
373
+ try {
374
+ const BetterSqlite3 = await import("better-sqlite3");
375
+ const Ctor = BetterSqlite3.default ?? BetterSqlite3;
376
+ db = new Ctor(dbPath, { readonly: true });
377
+ } catch {
378
+ console.warn(
379
+ "devrage: better-sqlite3 not available, skipping OpenCode sessions"
380
+ );
381
+ return;
382
+ }
383
+ try {
384
+ yield* queryUserMessages(db, options);
385
+ } finally {
386
+ db.close();
387
+ }
388
+ }
389
+ };
390
+ }
391
+ function* queryUserMessages(db, options) {
392
+ let query = `
393
+ SELECT
394
+ m.session_id,
395
+ m.time_created,
396
+ json_extract(p.data, '$.text') as text
397
+ FROM message m
398
+ JOIN part p ON p.message_id = m.id
399
+ WHERE json_extract(m.data, '$.role') = 'user'
400
+ AND json_extract(p.data, '$.type') = 'text'
401
+ `;
402
+ if (options?.since) {
403
+ const sinceMs = options.since.getTime();
404
+ query += ` AND m.time_created >= ${sinceMs}`;
405
+ }
406
+ query += ` ORDER BY m.time_created ASC`;
407
+ const rows = db.prepare(query).all();
408
+ for (const row of rows) {
409
+ if (!row.text || !row.text.trim()) continue;
410
+ yield {
411
+ text: row.text,
412
+ timestamp: new Date(row.time_created).toISOString(),
413
+ session: row.session_id
414
+ };
415
+ }
416
+ }
417
+
418
+ // src/adapters/zed.ts
419
+ import { readdir as readdir5, readFile as readFile3 } from "node:fs/promises";
420
+ import { existsSync as existsSync3 } from "node:fs";
421
+ import { homedir as homedir6 } from "node:os";
422
+ import { join as join6 } from "node:path";
423
+ function getZedPaths() {
424
+ if (process.platform === "darwin") {
425
+ const base2 = join6(homedir6(), "Library", "Application Support", "Zed");
426
+ return {
427
+ conversations: join6(base2, "conversations"),
428
+ db: join6(base2, "db")
429
+ };
430
+ }
431
+ const base = join6(
432
+ process.env["XDG_DATA_HOME"] ?? join6(homedir6(), ".local", "share"),
433
+ "zed"
434
+ );
435
+ return {
436
+ conversations: join6(base, "conversations"),
437
+ db: join6(base, "db")
438
+ };
439
+ }
440
+ function zedAdapter() {
441
+ return {
442
+ name: "zed",
443
+ async *messages(options) {
444
+ const paths = getZedPaths();
445
+ yield* parseTextThreads(paths.conversations, options);
446
+ yield* parseAgentThreads(paths.db, options);
447
+ }
448
+ };
449
+ }
450
+ async function* parseTextThreads(dir, _options) {
451
+ if (!existsSync3(dir)) return;
452
+ let files;
453
+ try {
454
+ files = await readdir5(dir);
455
+ } catch {
456
+ return;
457
+ }
458
+ const jsonFiles = files.filter((f) => f.endsWith(".json"));
459
+ for (const file of jsonFiles) {
460
+ const filePath = join6(dir, file);
461
+ const session = file.replace(".json", "");
462
+ try {
463
+ const raw = await readFile3(filePath, "utf-8");
464
+ const conversation = JSON.parse(raw);
465
+ if (!conversation.messages || !Array.isArray(conversation.messages)) continue;
466
+ for (const msg of conversation.messages) {
467
+ if (msg.role !== "user") continue;
468
+ const text = typeof msg.content === "string" ? msg.content : null;
469
+ if (!text) continue;
470
+ yield {
471
+ text,
472
+ session
473
+ };
474
+ }
475
+ } catch {
476
+ }
477
+ }
478
+ }
479
+ async function* parseAgentThreads(dbDir, _options) {
480
+ if (!existsSync3(dbDir)) return;
481
+ let dbFiles;
482
+ try {
483
+ const entries = await readdir5(dbDir);
484
+ dbFiles = entries.filter((f) => f.endsWith(".db"));
485
+ } catch {
486
+ return;
487
+ }
488
+ if (dbFiles.length === 0) return;
489
+ let Database;
490
+ try {
491
+ const mod = await import("better-sqlite3");
492
+ Database = mod.default ?? mod;
493
+ } catch {
494
+ return;
495
+ }
496
+ for (const dbFile of dbFiles) {
497
+ const dbPath = join6(dbDir, dbFile);
498
+ let db;
499
+ try {
500
+ db = new Database(
501
+ dbPath,
502
+ { readonly: true }
503
+ );
504
+ } catch {
505
+ continue;
506
+ }
507
+ try {
508
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
509
+ const tableNames = tables.map((t) => t.name);
510
+ const msgTable = tableNames.find(
511
+ (t) => t === "messages" || t === "thread_messages" || t.includes("message")
512
+ );
513
+ if (!msgTable) {
514
+ db.close();
515
+ continue;
516
+ }
517
+ const columns = db.prepare(`PRAGMA table_info("${msgTable}")`).all();
518
+ const colNames = columns.map((c) => c.name);
519
+ const hasRole = colNames.includes("role");
520
+ if (!hasRole) {
521
+ db.close();
522
+ continue;
523
+ }
524
+ const contentCol = colNames.includes("content") ? "content" : colNames.includes("body") ? "body" : "text";
525
+ let query = `SELECT "${contentCol}" as text FROM "${msgTable}" WHERE role = 'user'`;
526
+ const rows = db.prepare(query).all();
527
+ for (const row of rows) {
528
+ if (!row.text?.trim()) continue;
529
+ yield { text: row.text };
530
+ }
531
+ } catch {
532
+ } finally {
533
+ db.close();
534
+ }
535
+ }
536
+ }
537
+
538
+ // src/adapters/index.ts
539
+ var ADAPTERS = {
540
+ claude: claudeAdapter,
541
+ codex: codexAdapter,
542
+ opencode: opencodeAdapter,
543
+ amp: ampAdapter,
544
+ cline: clineAdapter,
545
+ zed: zedAdapter
546
+ };
547
+ function createAdapter(name) {
548
+ const factory = ADAPTERS[name];
549
+ if (!factory) {
550
+ throw new Error(
551
+ `unknown adapter: ${name} (available: ${Object.keys(ADAPTERS).join(", ")})`
552
+ );
553
+ }
554
+ return factory();
555
+ }
556
+ function allAdapters() {
557
+ return Object.values(ADAPTERS).map((f) => f());
558
+ }
559
+
560
+ // src/detector/index.ts
561
+ var WORDLIST = [
562
+ // === FUCK family (strong) ===
563
+ // Canonical forms
564
+ { word: "fuck", severity: "strong", group: "fuck" },
565
+ { word: "fucking", severity: "strong", group: "fuck" },
566
+ { word: "fucked", severity: "strong", group: "fuck" },
567
+ { word: "fucker", severity: "strong", group: "fuck" },
568
+ { word: "fuckin", severity: "strong", group: "fuck" },
569
+ { word: "fucks", severity: "strong", group: "fuck" },
570
+ // Compound words
571
+ { word: "motherfucker", severity: "strong", group: "fuck" },
572
+ { word: "motherfucking", severity: "strong", group: "fuck" },
573
+ { word: "mothafucka", severity: "strong", group: "fuck" },
574
+ { word: "fuckup", severity: "strong", group: "fuck" },
575
+ { word: "fuckoff", severity: "strong", group: "fuck" },
576
+ { word: "clusterfuck", severity: "strong", group: "fuck" },
577
+ { word: "fuckwit", severity: "strong", group: "fuck" },
578
+ { word: "fucktard", severity: "strong", group: "fuck" },
579
+ { word: "fuckface", severity: "strong", group: "fuck" },
580
+ { word: "fuckhead", severity: "strong", group: "fuck" },
581
+ // Typos — transpositions
582
+ { word: "fukc", severity: "strong", group: "fuck" },
583
+ { word: "fukcing", severity: "strong", group: "fuck" },
584
+ { word: "fukced", severity: "strong", group: "fuck" },
585
+ { word: "fukcer", severity: "strong", group: "fuck" },
586
+ { word: "fcuk", severity: "strong", group: "fuck" },
587
+ { word: "fcuking", severity: "strong", group: "fuck" },
588
+ { word: "fcuked", severity: "strong", group: "fuck" },
589
+ { word: "fuk", severity: "strong", group: "fuck" },
590
+ { word: "fuking", severity: "strong", group: "fuck" },
591
+ { word: "fuked", severity: "strong", group: "fuck" },
592
+ { word: "fuker", severity: "strong", group: "fuck" },
593
+ { word: "fuxk", severity: "strong", group: "fuck" },
594
+ { word: "fuxking", severity: "strong", group: "fuck" },
595
+ // === SHIT family (strong) ===
596
+ { word: "shit", severity: "strong", group: "shit" },
597
+ { word: "shitty", severity: "strong", group: "shit" },
598
+ { word: "shitting", severity: "strong", group: "shit" },
599
+ { word: "shits", severity: "strong", group: "shit" },
600
+ { word: "shitted", severity: "strong", group: "shit" },
601
+ // Compound words
602
+ { word: "bullshit", severity: "strong", group: "shit" },
603
+ { word: "horseshit", severity: "strong", group: "shit" },
604
+ { word: "dipshit", severity: "strong", group: "shit" },
605
+ { word: "shitshow", severity: "strong", group: "shit" },
606
+ { word: "shithead", severity: "strong", group: "shit" },
607
+ { word: "shithole", severity: "strong", group: "shit" },
608
+ { word: "shitface", severity: "strong", group: "shit" },
609
+ { word: "shitfaced", severity: "strong", group: "shit" },
610
+ { word: "shitstain", severity: "strong", group: "shit" },
611
+ { word: "shitbag", severity: "strong", group: "shit" },
612
+ // Typos
613
+ { word: "hsit", severity: "strong", group: "shit" },
614
+ { word: "siht", severity: "strong", group: "shit" },
615
+ { word: "shti", severity: "strong", group: "shit" },
616
+ { word: "sjit", severity: "strong", group: "shit" },
617
+ { word: "shjt", severity: "strong", group: "shit" },
618
+ { word: "bulshit", severity: "strong", group: "shit" },
619
+ { word: "bullsht", severity: "strong", group: "shit" },
620
+ // === ASS family (moderate) ===
621
+ { word: "ass", severity: "moderate", group: "ass" },
622
+ { word: "asses", severity: "moderate", group: "ass" },
623
+ // Compound words (these are strong)
624
+ { word: "asshole", severity: "strong", group: "ass" },
625
+ { word: "assholes", severity: "strong", group: "ass" },
626
+ { word: "jackass", severity: "strong", group: "ass" },
627
+ { word: "dumbass", severity: "strong", group: "ass" },
628
+ { word: "fatass", severity: "moderate", group: "ass" },
629
+ { word: "asshat", severity: "strong", group: "ass" },
630
+ { word: "asswipe", severity: "strong", group: "ass" },
631
+ { word: "badass", severity: "mild", group: "ass" },
632
+ // === DAMN family (moderate) ===
633
+ { word: "damn", severity: "moderate", group: "damn" },
634
+ { word: "damned", severity: "moderate", group: "damn" },
635
+ { word: "damnit", severity: "moderate", group: "damn" },
636
+ { word: "dammit", severity: "moderate", group: "damn" },
637
+ { word: "goddamn", severity: "moderate", group: "damn" },
638
+ { word: "goddamnit", severity: "moderate", group: "damn" },
639
+ { word: "goddammit", severity: "moderate", group: "damn" },
640
+ // === BITCH family (strong) ===
641
+ { word: "bitch", severity: "strong", group: "bitch" },
642
+ { word: "bitches", severity: "strong", group: "bitch" },
643
+ { word: "bitching", severity: "strong", group: "bitch" },
644
+ { word: "bitchy", severity: "strong", group: "bitch" },
645
+ { word: "bitchass", severity: "strong", group: "bitch" },
646
+ // === BASTARD (strong) ===
647
+ { word: "bastard", severity: "strong", group: "bastard" },
648
+ { word: "bastards", severity: "strong", group: "bastard" },
649
+ // === PISS family (moderate) ===
650
+ { word: "piss", severity: "moderate", group: "piss" },
651
+ { word: "pissed", severity: "moderate", group: "piss" },
652
+ { word: "pissing", severity: "moderate", group: "piss" },
653
+ { word: "pissoff", severity: "moderate", group: "piss" },
654
+ // === DICK (moderate) ===
655
+ { word: "dick", severity: "moderate", group: "dick" },
656
+ { word: "dickhead", severity: "strong", group: "dick" },
657
+ // === CRAP (moderate) ===
658
+ { word: "crap", severity: "moderate", group: "crap" },
659
+ { word: "crappy", severity: "moderate", group: "crap" },
660
+ { word: "crapping", severity: "moderate", group: "crap" },
661
+ // === HELL (mild) ===
662
+ { word: "hell", severity: "mild", group: "hell" },
663
+ // === Abbreviations (mild) ===
664
+ { word: "wtf", severity: "mild", group: "wtf" },
665
+ { word: "stfu", severity: "mild", group: "stfu" },
666
+ { word: "lmfao", severity: "mild", group: "lmfao" },
667
+ { word: "lmao", severity: "mild", group: "lmao" },
668
+ // === CUNT (strong) ===
669
+ { word: "cunt", severity: "strong", group: "cunt" },
670
+ { word: "cunts", severity: "strong", group: "cunt" }
671
+ ];
672
+ function collapseRepeats(text) {
673
+ return text.replace(/(.)\1+/g, "$1");
674
+ }
675
+ function buildPattern(words) {
676
+ const sorted = [...words].sort((a, b) => b.word.length - a.word.length);
677
+ const pattern = sorted.map((w) => w.word).join("|");
678
+ return new RegExp(`\\b(${pattern})\\b`, "gi");
679
+ }
680
+ var DEFAULT_PATTERN = buildPattern(WORDLIST);
681
+ var WORD_MAP = new Map(WORDLIST.map((w) => [w.word.toLowerCase(), w]));
682
+ function detect(text) {
683
+ const matches = [];
684
+ const seen = /* @__PURE__ */ new Set();
685
+ runPattern(text, text.toLowerCase(), matches, seen);
686
+ const collapsed = collapseRepeats(text.toLowerCase());
687
+ if (collapsed !== text.toLowerCase()) {
688
+ runPattern(text, collapsed, matches, seen);
689
+ }
690
+ return { count: matches.length, matches };
691
+ }
692
+ function runPattern(_originalText, searchText, matches, seen) {
693
+ DEFAULT_PATTERN.lastIndex = 0;
694
+ let match;
695
+ while ((match = DEFAULT_PATTERN.exec(searchText)) !== null) {
696
+ if (seen.has(match.index)) continue;
697
+ const word = match[0].toLowerCase();
698
+ const entry = WORD_MAP.get(word);
699
+ if (!entry) continue;
700
+ seen.add(match.index);
701
+ matches.push({
702
+ word,
703
+ index: match.index,
704
+ severity: entry.severity,
705
+ group: entry.group
706
+ });
707
+ }
708
+ }
709
+
710
+ // src/commands/scan.ts
711
+ function parseArgs(args) {
712
+ const options = {};
713
+ for (let i = 0; i < args.length; i++) {
714
+ const arg = args[i];
715
+ if (arg === "--agent" || arg === "-a") {
716
+ options.agent = args[++i];
717
+ } else if (arg === "--since" || arg === "-s") {
718
+ const val = args[++i];
719
+ if (val) {
720
+ options.since = new Date(val);
721
+ if (isNaN(options.since.getTime())) {
722
+ console.error(`invalid date: ${val}`);
723
+ process.exit(1);
724
+ }
725
+ }
726
+ } else if (arg === "--help" || arg === "-h") {
727
+ console.log(`devrage scan \u2014 scan sessions for profanity
728
+
729
+ Options:
730
+ --agent, -a <name> Scan only a specific agent (claude, codex, opencode, amp, cline, zed)
731
+ --since, -s <date> Only scan messages after this date (ISO 8601)
732
+ --help, -h Show this help`);
733
+ process.exit(0);
734
+ }
735
+ }
736
+ return options;
737
+ }
738
+ async function scan(args) {
739
+ const options = parseArgs(args);
740
+ const adapters = options.agent ? [createAdapter(options.agent)] : allAdapters();
741
+ const groupTally = {};
742
+ const variantTally = {};
743
+ let totalMessages = 0;
744
+ let totalSwears = 0;
745
+ const perAgent = {};
746
+ for (const adapter of adapters) {
747
+ let agentMessages = 0;
748
+ let agentSwears = 0;
749
+ for await (const message of adapter.messages({ since: options.since })) {
750
+ totalMessages++;
751
+ agentMessages++;
752
+ const result = detect(message.text);
753
+ if (result.count > 0) {
754
+ totalSwears += result.count;
755
+ agentSwears += result.count;
756
+ for (const match of result.matches) {
757
+ groupTally[match.group] = (groupTally[match.group] ?? 0) + 1;
758
+ const variants = variantTally[match.group] ??= {};
759
+ variants[match.word] = (variants[match.word] ?? 0) + 1;
760
+ }
761
+ }
762
+ }
763
+ if (agentMessages > 0) {
764
+ perAgent[adapter.name] = { messages: agentMessages, swears: agentSwears };
765
+ }
766
+ }
767
+ console.log("");
768
+ console.log(" devrage report");
769
+ console.log(" ===============");
770
+ console.log("");
771
+ console.log(` messages scanned: ${totalMessages}`);
772
+ console.log(` total swears: ${totalSwears}`);
773
+ const activeAgents = Object.entries(perAgent);
774
+ if (activeAgents.length > 1) {
775
+ console.log("");
776
+ console.log(" by agent:");
777
+ for (const [name, stats] of activeAgents) {
778
+ const rate = (stats.swears / stats.messages * 100).toFixed(1);
779
+ console.log(
780
+ ` ${name.padEnd(10)} ${String(stats.swears).padStart(4)} swears in ${stats.messages} messages (${rate}%)`
781
+ );
782
+ }
783
+ }
784
+ if (totalSwears > 0) {
785
+ const sorted = Object.entries(groupTally).sort(([, a], [, b]) => b - a);
786
+ console.log("");
787
+ console.log(" top words:");
788
+ for (const [group, count] of sorted.slice(0, 10)) {
789
+ const variants = variantTally[group] ?? {};
790
+ const variantList = Object.entries(variants).sort(([, a], [, b]) => b - a).filter(([v]) => v !== group).slice(0, 15).map(([v, c]) => `${v} ${c}`).join(", ");
791
+ const suffix = variantList ? ` (${variantList})` : "";
792
+ console.log(` ${group.padEnd(12)} ${String(count).padStart(4)}${suffix}`);
793
+ }
794
+ }
795
+ console.log("");
796
+ if (totalSwears === 0) {
797
+ console.log(" squeaky clean! not a single swear found.");
798
+ console.log("");
799
+ }
800
+ }
801
+
802
+ // src/cli.ts
803
+ var COMMANDS = {
804
+ scan
805
+ };
806
+ function usage() {
807
+ console.log(`devrage \u2014 count how many times you swear at your coding agents
808
+
809
+ Usage:
810
+ devrage <command> [options]
811
+
812
+ Commands:
813
+ scan Scan sessions for profanity
814
+
815
+ Options:
816
+ --help, -h Show this help message
817
+ --version Show version
818
+
819
+ Examples:
820
+ devrage scan
821
+ devrage scan --agent claude
822
+ devrage scan --since 2025-01-01`);
823
+ }
824
+ async function main() {
825
+ const args = process.argv.slice(2);
826
+ const command = args[0];
827
+ if (command === "--help" || command === "-h") {
828
+ usage();
829
+ process.exit(0);
830
+ }
831
+ if (command === "--version") {
832
+ console.log("0.0.1");
833
+ process.exit(0);
834
+ }
835
+ const handler = command ? COMMANDS[command] : void 0;
836
+ if (handler) {
837
+ await handler(args.slice(1));
838
+ } else {
839
+ await scan(args);
840
+ }
841
+ }
842
+ main().catch((err) => {
843
+ console.error(err);
844
+ process.exit(1);
845
+ });
846
+ //# sourceMappingURL=cli.js.map