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.
- package/README.md +67 -0
- package/dist/commands/init.js +319 -0
- package/dist/commands/logout.js +57 -0
- package/dist/commands/show.js +59 -0
- package/dist/commands/start.js +34 -0
- package/dist/commands/status.js +126 -0
- package/dist/commands/stop.js +43 -0
- package/dist/commands/sync.js +117 -0
- package/dist/index.js +94 -0
- package/dist/lib/config.js +132 -0
- package/dist/lib/constants.js +18 -0
- package/dist/lib/path.js +26 -0
- package/dist/services/api.js +211 -0
- package/dist/services/claude-capture.js +368 -0
- package/dist/services/daemon.js +432 -0
- package/dist/types/index.js +13 -0
- package/package.json +44 -0
|
@@ -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
|