promptcase 1.0.4

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.
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Claude prompt capture service
3
+ *
4
+ * Reads prompts from ~/.claude/ directory.
5
+ *
6
+ * Data sources:
7
+ * - ~/.claude/projects/[encoded-cwd]/[session-id].jsonl (project transcripts, one file per session)
8
+ * - ~/.claude/history.jsonl (global prompt history, display-only)
9
+ *
10
+ * The encoded-cwd format replaces "/" with "-" (e.g. "-Users-siddhikagupta-Desktop-PromptCase"
11
+ * decodes to "/Users/siddhikagupta/Desktop/PromptCase").
12
+ *
13
+ * Each .jsonl project file has lines like:
14
+ * {"type":"user","message":{"role":"user","content":[...]},"timestamp":"...","sessionId":"..."}
15
+ * {"type":"queue-operation","operation":"enqueue",...} (skip)
16
+ * {"type":"assistant",...} (skip)
17
+ *
18
+ * We extract only `type === "user"` lines and skip noise like
19
+ * `<local-command-caveat>`, `<ide_opened_file>`, `<command-name>`, and
20
+ * `isMeta: true` system messages.
21
+ */
22
+ import { promises as fs, createReadStream } from 'fs';
23
+ import * as path from 'path';
24
+ import * as os from 'os';
25
+ import * as readline from 'node:readline';
26
+ import { createHash } from 'crypto';
27
+ import { PROMPT_MIN_LENGTH } from '../lib/constants.js';
28
+ const MIN_PROMPT_LENGTH = PROMPT_MIN_LENGTH;
29
+ // Patterns that indicate non-prompt content (system/meta messages). We
30
+ // strip these from joined text; if nothing meaningful remains, the
31
+ // entry is skipped entirely.
32
+ const NOISE_PATTERNS = [
33
+ /^<local-command-caveat>/i,
34
+ /^<local-command-stdout>/i,
35
+ /^<local-command-stderr>/i,
36
+ /^<ide_opened_file>/i,
37
+ /^<command-name>/i,
38
+ /^<command-message>/i,
39
+ /^<command-args>/i,
40
+ ];
41
+ const HTML_TAG = /<[^>]+>/g;
42
+ // Compiled once at module load — replaces the runtime regex allocation
43
+ // that the original code did for every prompt.
44
+ const NOISE_PEEK = /^<local-command-|^<ide_opened|^<command-/i;
45
+ export class ClaudeCaptureService {
46
+ claudeDir;
47
+ historyFile;
48
+ projectsDir;
49
+ constructor() {
50
+ this.claudeDir = path.join(os.homedir(), '.claude');
51
+ this.historyFile = path.join(this.claudeDir, 'history.jsonl');
52
+ this.projectsDir = path.join(this.claudeDir, 'projects');
53
+ }
54
+ /**
55
+ * Get all Claude sessions from projects directory.
56
+ *
57
+ * Real layout: each subdirectory under `~/.claude/projects/` represents a
58
+ * working directory (path with "/" replaced by "-"). Inside that, each
59
+ * `*.jsonl` file is one session (filename = sessionId).
60
+ *
61
+ * All disk IO is non-blocking — important because users on machines
62
+ * with hundreds of session files shouldn't have the event loop stalled
63
+ * by `statSync` per file.
64
+ */
65
+ async getSessions() {
66
+ let projectEntries;
67
+ try {
68
+ projectEntries = await fs.readdir(this.projectsDir, { withFileTypes: true });
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ const sessions = [];
74
+ for (const projectEntry of projectEntries) {
75
+ if (!projectEntry.isDirectory())
76
+ continue;
77
+ if (projectEntry.name.startsWith('.'))
78
+ continue;
79
+ const projectDirPath = path.join(this.projectsDir, projectEntry.name);
80
+ const projectPath = this.decodeProjectPath(projectEntry.name);
81
+ let files;
82
+ try {
83
+ files = await fs.readdir(projectDirPath);
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ const jsonlFiles = files.filter((f) => f.endsWith('.jsonl') && !f.startsWith('.'));
89
+ // Stat all files in one parallel batch — fs.stat on macOS is a per-file
90
+ // syscall, batching with Promise.all is significantly faster than
91
+ // statSync in a loop.
92
+ const stats = await Promise.all(jsonlFiles.map((file) => fs.stat(path.join(projectDirPath, file)).then((s) => ({ file, stat: s }), () => null)));
93
+ for (const entry of stats) {
94
+ if (!entry)
95
+ continue;
96
+ const sessionId = entry.file.replace(/\.jsonl$/, '');
97
+ sessions.push({
98
+ id: sessionId,
99
+ projectId: sessionId,
100
+ projectPath,
101
+ startedAt: entry.stat.birthtime,
102
+ lastModified: entry.stat.mtime,
103
+ });
104
+ }
105
+ }
106
+ return sessions.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
107
+ }
108
+ /**
109
+ * Decode an encoded cwd directory name back to a real path.
110
+ * "-Users-siddhikagupta-Desktop-PromptCase" → "/Users/siddhikagupta/Desktop/PromptCase"
111
+ */
112
+ decodeProjectPath(encoded) {
113
+ return '/' + encoded.replace(/^-/, '').replace(/-/g, '/');
114
+ }
115
+ /**
116
+ * Encode a real project path to its directory name under projects/.
117
+ * Inverse of `decodeProjectPath`. Used to locate session files for a given
118
+ * session record returned from `getSessions()`.
119
+ */
120
+ encodeProjectPath(projectPath) {
121
+ return '-' + projectPath.replace(/^\//, '').replace(/\//g, '-');
122
+ }
123
+ /**
124
+ * Parse user prompts from a session file. Streams the file line by line
125
+ * so we don't have to load multi-MB transcripts into memory.
126
+ */
127
+ async parseSessionPrompts(sessionFile, projectPath) {
128
+ let stream;
129
+ try {
130
+ stream = createReadStream(sessionFile);
131
+ }
132
+ catch {
133
+ return [];
134
+ }
135
+ const rl = readline.createInterface({
136
+ input: stream,
137
+ crlfDelay: Infinity,
138
+ });
139
+ const prompts = [];
140
+ let messageIndex = 0;
141
+ const sessionId = path.basename(sessionFile, '.jsonl');
142
+ try {
143
+ for await (const line of rl) {
144
+ if (!line.trim())
145
+ continue;
146
+ let parsed;
147
+ try {
148
+ parsed = JSON.parse(line);
149
+ }
150
+ catch {
151
+ continue;
152
+ }
153
+ // Only user-typed messages; skip assistant/queue-operation/meta entries.
154
+ if (parsed.type !== 'user')
155
+ continue;
156
+ if (parsed.isMeta === true)
157
+ continue;
158
+ const content = this.extractPromptText(parsed);
159
+ if (!content)
160
+ continue;
161
+ const trimmed = content.trim();
162
+ if (trimmed.length < MIN_PROMPT_LENGTH)
163
+ continue;
164
+ if (this.isOnlyNoise(trimmed))
165
+ continue;
166
+ const timestamp = parsed.timestamp
167
+ ? new Date(parsed.timestamp)
168
+ : parsed.captured_at
169
+ ? new Date(parsed.captured_at)
170
+ : new Date();
171
+ prompts.push({
172
+ id: parsed.uuid || `msg_${sessionId}_${messageIndex}`,
173
+ content: trimmed,
174
+ timestamp,
175
+ sessionId: parsed.sessionId || sessionId,
176
+ projectPath,
177
+ messageIndex: messageIndex++,
178
+ });
179
+ }
180
+ }
181
+ finally {
182
+ // Always release the file handle when we're done, even if the
183
+ // consumer broke out of the loop early (rare, but worth defending).
184
+ rl.close();
185
+ stream.destroy();
186
+ }
187
+ return prompts;
188
+ }
189
+ /**
190
+ * Extract the user prompt text from a parsed user message.
191
+ * Handles the three observed content shapes:
192
+ * 1. message.content is a string
193
+ * 2. message.content is an array of {type:"text", text:"..."} blocks
194
+ * 3. content is at the top level (older format)
195
+ */
196
+ extractPromptText(message) {
197
+ const content = message.message?.content ?? message.content;
198
+ if (content == null)
199
+ return null;
200
+ if (typeof content === 'string') {
201
+ return content;
202
+ }
203
+ if (Array.isArray(content)) {
204
+ const textParts = [];
205
+ for (const part of content) {
206
+ if (!part || typeof part !== 'object')
207
+ continue;
208
+ // Accept both explicit {type:"text"} blocks and blocks that omit
209
+ // `type` but still carry `text` (observed in some Claude Code output).
210
+ if (typeof part.text === 'string') {
211
+ textParts.push(part.text);
212
+ }
213
+ }
214
+ return textParts.length > 0 ? textParts.join('\n') : null;
215
+ }
216
+ return null;
217
+ }
218
+ /**
219
+ * Quick test — does the text start with a known noise tag?
220
+ * Avoids the expensive replace+strip if there's clearly nothing to clean.
221
+ */
222
+ isOnlyNoise(text) {
223
+ if (!NOISE_PEEK.test(text))
224
+ return false;
225
+ let cleaned = text;
226
+ for (const pattern of NOISE_PATTERNS) {
227
+ cleaned = cleaned.replace(pattern, '');
228
+ }
229
+ cleaned = cleaned.replace(HTML_TAG, '').trim();
230
+ return cleaned.length < MIN_PROMPT_LENGTH;
231
+ }
232
+ /**
233
+ * Generate a title from prompt content (first line or first 100 chars)
234
+ */
235
+ generateTitle(content) {
236
+ const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? content;
237
+ const trimmed = firstLine.trim();
238
+ if (trimmed.length <= 100)
239
+ return trimmed;
240
+ return trimmed.slice(0, 100) + '...';
241
+ }
242
+ /**
243
+ * Read prompts from the global history.jsonl file.
244
+ * Each line: {display, pastedContents, timestamp, project, sessionId}
245
+ * `display` is the user-entered prompt text.
246
+ * `timestamp` is in milliseconds (Unix epoch ms).
247
+ */
248
+ async getHistoryPrompts(limit) {
249
+ let content;
250
+ try {
251
+ content = await fs.readFile(this.historyFile, 'utf-8');
252
+ }
253
+ catch {
254
+ return [];
255
+ }
256
+ const lines = content.split('\n');
257
+ const results = [];
258
+ for (const line of lines) {
259
+ if (!line.trim())
260
+ continue;
261
+ let obj;
262
+ try {
263
+ obj = JSON.parse(line);
264
+ }
265
+ catch {
266
+ continue;
267
+ }
268
+ const display = (obj.display ?? '').toString();
269
+ if (display.trim().length < MIN_PROMPT_LENGTH)
270
+ continue;
271
+ if (this.isOnlyNoise(display.trim()))
272
+ continue;
273
+ const ts = typeof obj.timestamp === 'number' ? new Date(obj.timestamp) : new Date();
274
+ const contentClean = display.trim();
275
+ results.push({
276
+ content: contentClean,
277
+ title: this.generateTitle(contentClean),
278
+ timestamp: ts,
279
+ projectPath: obj.project ?? '',
280
+ sessionId: obj.sessionId ?? 'history',
281
+ hash: this.sha256Hash(contentClean),
282
+ });
283
+ }
284
+ // Newest first
285
+ results.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
286
+ return results.slice(0, limit);
287
+ }
288
+ /**
289
+ * Get all prompts from all sources (project transcripts + history),
290
+ * deduplicated by SHA-256 content hash, sorted newest first.
291
+ */
292
+ async getAllPrompts(since, limit = 100) {
293
+ const sessions = await this.getSessions();
294
+ const seenHashes = new Set();
295
+ const allPrompts = [];
296
+ // 1) Project transcripts — primary source, richer content.
297
+ for (const session of sessions) {
298
+ const encoded = this.encodeProjectPath(session.projectPath);
299
+ const sessionFile = path.join(this.projectsDir, encoded, session.id + '.jsonl');
300
+ const prompts = await this.parseSessionPrompts(sessionFile, session.projectPath);
301
+ for (const prompt of prompts) {
302
+ if (since && prompt.timestamp < since)
303
+ continue;
304
+ const hash = this.sha256Hash(prompt.content);
305
+ if (seenHashes.has(hash))
306
+ continue;
307
+ seenHashes.add(hash);
308
+ allPrompts.push({
309
+ content: prompt.content,
310
+ title: this.generateTitle(prompt.content),
311
+ timestamp: prompt.timestamp,
312
+ projectPath: prompt.projectPath,
313
+ sessionId: prompt.sessionId,
314
+ hash,
315
+ });
316
+ }
317
+ }
318
+ // 2) history.jsonl — catches prompts from `claude -p "..."` and other
319
+ // invocations that may not produce a full transcript.
320
+ const historyPrompts = await this.getHistoryPrompts(limit * 2);
321
+ for (const prompt of historyPrompts) {
322
+ if (since && prompt.timestamp < since)
323
+ continue;
324
+ if (seenHashes.has(prompt.hash))
325
+ continue;
326
+ seenHashes.add(prompt.hash);
327
+ allPrompts.push(prompt);
328
+ }
329
+ allPrompts.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
330
+ return allPrompts.slice(0, limit);
331
+ }
332
+ /**
333
+ * Count session files quickly for diagnostics (e.g. status output).
334
+ * Reads the projects directory once and returns the count of `.jsonl`
335
+ * files across all project subdirectories. Faster than `getSessions()`
336
+ * because it skips the per-file stat.
337
+ */
338
+ async countSessionFiles() {
339
+ let projectEntries;
340
+ try {
341
+ projectEntries = await fs.readdir(this.projectsDir, { withFileTypes: true });
342
+ }
343
+ catch {
344
+ return 0;
345
+ }
346
+ let total = 0;
347
+ for (const entry of projectEntries) {
348
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
349
+ continue;
350
+ try {
351
+ const files = await fs.readdir(path.join(this.projectsDir, entry.name));
352
+ total += files.filter((f) => f.endsWith('.jsonl') && !f.startsWith('.')).length;
353
+ }
354
+ catch {
355
+ // skip unreadable dir
356
+ }
357
+ }
358
+ return total;
359
+ }
360
+ /**
361
+ * SHA-256 of the full content — matches the server's hash so dedup works
362
+ * across CLI runs.
363
+ */
364
+ sha256Hash(content) {
365
+ return createHash('sha256').update(content).digest('hex');
366
+ }
367
+ }
368
+ //# sourceMappingURL=claude-capture.js.map