@promptcellar/pc 0.4.1 → 0.5.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.
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
# Multi-Prompt Capture Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Capture all user prompts in real-time (not just the first), grouped by session ID.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Switch Claude Code from Stop hook to UserPromptSubmit hook. Add deduplication state tracking for Codex CLI. Gemini already fires per-prompt, just verify session handling.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js, ES modules, Conf for state storage
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Task 1: Create State Management Utility
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Create: `src/lib/state.js`
|
|
17
|
+
|
|
18
|
+
**Step 1: Create the state utility file**
|
|
19
|
+
|
|
20
|
+
```javascript
|
|
21
|
+
import Conf from 'conf';
|
|
22
|
+
|
|
23
|
+
const state = new Conf({
|
|
24
|
+
projectName: 'promptcellar',
|
|
25
|
+
configName: 'capture-state',
|
|
26
|
+
schema: {
|
|
27
|
+
threads: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
default: {}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get the last captured index for a thread.
|
|
38
|
+
* @param {string} threadId
|
|
39
|
+
* @returns {number}
|
|
40
|
+
*/
|
|
41
|
+
export function getLastCapturedIndex(threadId) {
|
|
42
|
+
const threads = state.get('threads');
|
|
43
|
+
return threads[threadId]?.lastIndex || 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Save the last captured index for a thread.
|
|
48
|
+
* @param {string} threadId
|
|
49
|
+
* @param {number} lastIndex
|
|
50
|
+
*/
|
|
51
|
+
export function saveLastCapturedIndex(threadId, lastIndex) {
|
|
52
|
+
const threads = state.get('threads');
|
|
53
|
+
threads[threadId] = {
|
|
54
|
+
lastIndex,
|
|
55
|
+
updatedAt: Date.now()
|
|
56
|
+
};
|
|
57
|
+
state.set('threads', threads);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Clean up stale thread entries older than maxAgeMs.
|
|
62
|
+
* @param {number} maxAgeMs - Default 24 hours
|
|
63
|
+
*/
|
|
64
|
+
export function cleanupStaleThreads(maxAgeMs = MAX_AGE_MS) {
|
|
65
|
+
const threads = state.get('threads');
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
let changed = false;
|
|
68
|
+
|
|
69
|
+
for (const [threadId, data] of Object.entries(threads)) {
|
|
70
|
+
if (now - data.updatedAt > maxAgeMs) {
|
|
71
|
+
delete threads[threadId];
|
|
72
|
+
changed = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (changed) {
|
|
77
|
+
state.set('threads', threads);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default { getLastCapturedIndex, saveLastCapturedIndex, cleanupStaleThreads };
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Step 2: Commit**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
git add src/lib/state.js
|
|
88
|
+
git commit -m "feat: add state management for capture deduplication"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Task 2: Rewrite Claude Code Hook for UserPromptSubmit
|
|
94
|
+
|
|
95
|
+
**Files:**
|
|
96
|
+
- Modify: `hooks/prompt-capture.js`
|
|
97
|
+
|
|
98
|
+
**Step 1: Rewrite the hook for UserPromptSubmit**
|
|
99
|
+
|
|
100
|
+
Replace entire file with:
|
|
101
|
+
|
|
102
|
+
```javascript
|
|
103
|
+
#!/usr/bin/env node
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Claude Code UserPromptSubmit hook for capturing prompts to PromptCellar.
|
|
107
|
+
*
|
|
108
|
+
* This script is called by Claude Code on every user prompt submission.
|
|
109
|
+
* UserPromptSubmit hooks receive JSON via stdin with:
|
|
110
|
+
* - session_id: session identifier
|
|
111
|
+
* - prompt: the user's prompt text
|
|
112
|
+
* - cwd: working directory
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
import { capturePrompt } from '../src/lib/api.js';
|
|
116
|
+
import { getFullContext } from '../src/lib/context.js';
|
|
117
|
+
import { isLoggedIn, isVaultAvailable } from '../src/lib/config.js';
|
|
118
|
+
import { encryptPrompt } from '../src/lib/crypto.js';
|
|
119
|
+
import { requireVaultKey } from '../src/lib/keychain.js';
|
|
120
|
+
|
|
121
|
+
async function readStdin() {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
let data = '';
|
|
124
|
+
process.stdin.setEncoding('utf8');
|
|
125
|
+
process.stdin.on('data', chunk => data += chunk);
|
|
126
|
+
process.stdin.on('end', () => resolve(data));
|
|
127
|
+
|
|
128
|
+
// Timeout after 1 second if no input
|
|
129
|
+
setTimeout(() => resolve(data), 1000);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function main() {
|
|
134
|
+
try {
|
|
135
|
+
const input = await readStdin();
|
|
136
|
+
|
|
137
|
+
if (!input.trim()) {
|
|
138
|
+
process.exit(0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!isLoggedIn()) {
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!isVaultAvailable()) {
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const event = JSON.parse(input);
|
|
150
|
+
|
|
151
|
+
// UserPromptSubmit provides the prompt directly
|
|
152
|
+
if (!event.prompt) {
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const promptContent = event.prompt.trim();
|
|
157
|
+
if (!promptContent) {
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const context = getFullContext('claude-code');
|
|
162
|
+
|
|
163
|
+
// Override with event data if available
|
|
164
|
+
if (event.cwd) {
|
|
165
|
+
context.working_directory = event.cwd;
|
|
166
|
+
}
|
|
167
|
+
if (event.session_id) {
|
|
168
|
+
context.session_id = event.session_id;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const vaultKey = await requireVaultKey({ silent: true });
|
|
172
|
+
if (!vaultKey) {
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
175
|
+
const { encrypted_content, content_iv } = encryptPrompt(promptContent, vaultKey);
|
|
176
|
+
|
|
177
|
+
await capturePrompt({
|
|
178
|
+
...context,
|
|
179
|
+
encrypted_content,
|
|
180
|
+
content_iv
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
} catch {
|
|
184
|
+
// Fail silently — stderr from hooks can cause issues
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
main();
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**Step 2: Commit**
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
git add hooks/prompt-capture.js
|
|
196
|
+
git commit -m "feat: rewrite Claude hook for UserPromptSubmit (real-time capture)"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Task 3: Update Codex Hook for Multi-Prompt Capture
|
|
202
|
+
|
|
203
|
+
**Files:**
|
|
204
|
+
- Modify: `hooks/codex-capture.js`
|
|
205
|
+
|
|
206
|
+
**Step 1: Update the hook with deduplication**
|
|
207
|
+
|
|
208
|
+
Replace entire file with:
|
|
209
|
+
|
|
210
|
+
```javascript
|
|
211
|
+
#!/usr/bin/env node
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Codex CLI notify hook for capturing prompts to PromptCellar.
|
|
215
|
+
*
|
|
216
|
+
* Codex calls this script with a JSON argument containing:
|
|
217
|
+
* - type: event type (e.g., 'agent-turn-complete')
|
|
218
|
+
* - thread-id: session identifier
|
|
219
|
+
* - turn-id: turn identifier
|
|
220
|
+
* - cwd: working directory
|
|
221
|
+
* - input-messages: array of user messages (accumulates over session)
|
|
222
|
+
* - last-assistant-message: final assistant response
|
|
223
|
+
*
|
|
224
|
+
* This hook captures ALL new user messages since last capture using
|
|
225
|
+
* state tracking to avoid duplicates.
|
|
226
|
+
*/
|
|
227
|
+
|
|
228
|
+
import { capturePrompt } from '../src/lib/api.js';
|
|
229
|
+
import { getFullContext } from '../src/lib/context.js';
|
|
230
|
+
import { isLoggedIn, isVaultAvailable } from '../src/lib/config.js';
|
|
231
|
+
import { encryptPrompt } from '../src/lib/crypto.js';
|
|
232
|
+
import { requireVaultKey } from '../src/lib/keychain.js';
|
|
233
|
+
import { getLastCapturedIndex, saveLastCapturedIndex, cleanupStaleThreads } from '../src/lib/state.js';
|
|
234
|
+
|
|
235
|
+
function extractContent(message) {
|
|
236
|
+
if (typeof message === 'string') {
|
|
237
|
+
return message;
|
|
238
|
+
}
|
|
239
|
+
if (message.content) {
|
|
240
|
+
return Array.isArray(message.content)
|
|
241
|
+
? message.content.filter(c => c.type === 'text').map(c => c.text).join('\n')
|
|
242
|
+
: message.content;
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function main() {
|
|
248
|
+
// Codex passes JSON as first argument
|
|
249
|
+
const jsonArg = process.argv[2];
|
|
250
|
+
|
|
251
|
+
if (!jsonArg) {
|
|
252
|
+
process.exit(0);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!isLoggedIn()) {
|
|
256
|
+
process.exit(0);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!isVaultAvailable()) {
|
|
260
|
+
process.exit(0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const event = JSON.parse(jsonArg);
|
|
265
|
+
|
|
266
|
+
// Only capture on agent-turn-complete
|
|
267
|
+
if (event.type !== 'agent-turn-complete') {
|
|
268
|
+
process.exit(0);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const inputMessages = event['input-messages'] || [];
|
|
272
|
+
if (inputMessages.length === 0) {
|
|
273
|
+
process.exit(0);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const threadId = event['thread-id'];
|
|
277
|
+
if (!threadId) {
|
|
278
|
+
process.exit(0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Clean up stale threads periodically
|
|
282
|
+
cleanupStaleThreads();
|
|
283
|
+
|
|
284
|
+
// Get last captured index to avoid duplicates
|
|
285
|
+
const lastIndex = getLastCapturedIndex(threadId);
|
|
286
|
+
const newMessages = inputMessages.slice(lastIndex);
|
|
287
|
+
|
|
288
|
+
if (newMessages.length === 0) {
|
|
289
|
+
process.exit(0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const vaultKey = await requireVaultKey({ silent: true });
|
|
293
|
+
if (!vaultKey) {
|
|
294
|
+
process.exit(0);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Build base context once
|
|
298
|
+
const baseContext = getFullContext('codex');
|
|
299
|
+
if (event.cwd) {
|
|
300
|
+
baseContext.working_directory = event.cwd;
|
|
301
|
+
}
|
|
302
|
+
baseContext.session_id = threadId;
|
|
303
|
+
|
|
304
|
+
// Capture each new message
|
|
305
|
+
for (const message of newMessages) {
|
|
306
|
+
const content = extractContent(message);
|
|
307
|
+
if (!content || !content.trim()) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const { encrypted_content, content_iv } = encryptPrompt(content.trim(), vaultKey);
|
|
312
|
+
|
|
313
|
+
await capturePrompt({
|
|
314
|
+
...baseContext,
|
|
315
|
+
encrypted_content,
|
|
316
|
+
content_iv
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Update state with new index
|
|
321
|
+
saveLastCapturedIndex(threadId, inputMessages.length);
|
|
322
|
+
|
|
323
|
+
} catch {
|
|
324
|
+
// Fail silently to not disrupt Codex
|
|
325
|
+
process.exit(0);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
main();
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**Step 2: Commit**
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
git add hooks/codex-capture.js
|
|
336
|
+
git commit -m "feat: update Codex hook to capture all prompts with deduplication"
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Task 4: Verify Gemini Hook Session Handling
|
|
342
|
+
|
|
343
|
+
**Files:**
|
|
344
|
+
- Modify: `hooks/gemini-capture.js` (minor update)
|
|
345
|
+
|
|
346
|
+
**Step 1: Review and verify session_id handling**
|
|
347
|
+
|
|
348
|
+
The Gemini hook already captures per-prompt via BeforeAgent. Verify that session_id is properly included. The current implementation at lines 81-83 already handles this:
|
|
349
|
+
|
|
350
|
+
```javascript
|
|
351
|
+
if (event.session_id) {
|
|
352
|
+
context.session_id = event.session_id;
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
No changes needed - the hook already works correctly for multi-prompt capture.
|
|
357
|
+
|
|
358
|
+
**Step 2: Commit (documentation only if no changes)**
|
|
359
|
+
|
|
360
|
+
No commit needed - Gemini hook already supports multi-prompt capture.
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## Task 5: Update Setup Command for UserPromptSubmit Hook
|
|
365
|
+
|
|
366
|
+
**Files:**
|
|
367
|
+
- Modify: `src/commands/setup.js`
|
|
368
|
+
|
|
369
|
+
**Step 1: Update Claude Code hook configuration**
|
|
370
|
+
|
|
371
|
+
Change the `setupClaudeCode` function to use `UserPromptSubmit` instead of `Stop`.
|
|
372
|
+
|
|
373
|
+
Find and replace in `setupClaudeCode` function (around lines 167-210):
|
|
374
|
+
|
|
375
|
+
Change line 61-64 (isClaudeHookInstalled):
|
|
376
|
+
```javascript
|
|
377
|
+
function isClaudeHookInstalled(settings) {
|
|
378
|
+
const matchers = settings.hooks?.UserPromptSubmit || [];
|
|
379
|
+
return matchers.some(matcher =>
|
|
380
|
+
matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Change lines 187-189 (remove existing hook):
|
|
386
|
+
```javascript
|
|
387
|
+
// Remove existing hook matchers that contain our hook
|
|
388
|
+
settings.hooks.UserPromptSubmit = (settings.hooks.UserPromptSubmit || []).filter(matcher =>
|
|
389
|
+
!matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
|
|
390
|
+
);
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Change lines 193-206 (add new hook):
|
|
394
|
+
```javascript
|
|
395
|
+
// Ensure hooks.UserPromptSubmit exists
|
|
396
|
+
if (!settings.hooks) {
|
|
397
|
+
settings.hooks = {};
|
|
398
|
+
}
|
|
399
|
+
if (!settings.hooks.UserPromptSubmit) {
|
|
400
|
+
settings.hooks.UserPromptSubmit = [];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Add the UserPromptSubmit hook
|
|
404
|
+
settings.hooks.UserPromptSubmit.push({
|
|
405
|
+
matcher: '*',
|
|
406
|
+
hooks: [{
|
|
407
|
+
type: 'command',
|
|
408
|
+
command: 'pc-capture'
|
|
409
|
+
}]
|
|
410
|
+
});
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
**Step 2: Update unsetup function**
|
|
414
|
+
|
|
415
|
+
Change lines 317-324 (unsetup Claude hook):
|
|
416
|
+
```javascript
|
|
417
|
+
// Remove Claude hook
|
|
418
|
+
const claudeSettings = getClaudeSettings();
|
|
419
|
+
if (isClaudeHookInstalled(claudeSettings)) {
|
|
420
|
+
claudeSettings.hooks.UserPromptSubmit = (claudeSettings.hooks.UserPromptSubmit || []).filter(matcher =>
|
|
421
|
+
!matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
|
|
422
|
+
);
|
|
423
|
+
saveClaudeSettings(claudeSettings);
|
|
424
|
+
console.log(chalk.green(' Removed Claude Code hook.'));
|
|
425
|
+
removed = true;
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
**Step 3: Commit**
|
|
430
|
+
|
|
431
|
+
```bash
|
|
432
|
+
git add src/commands/setup.js
|
|
433
|
+
git commit -m "feat: update setup to use UserPromptSubmit hook for Claude Code"
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## Task 6: Bump Version and Final Commit
|
|
439
|
+
|
|
440
|
+
**Files:**
|
|
441
|
+
- Modify: `package.json`
|
|
442
|
+
|
|
443
|
+
**Step 1: Bump version to 0.5.0**
|
|
444
|
+
|
|
445
|
+
This is a feature release (captures all prompts instead of just first).
|
|
446
|
+
|
|
447
|
+
Change line 3:
|
|
448
|
+
```json
|
|
449
|
+
"version": "0.5.0",
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
**Step 2: Commit version bump**
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
git add package.json
|
|
456
|
+
git commit -m "chore: bump version to 0.5.0"
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## Task 7: Test the Implementation
|
|
462
|
+
|
|
463
|
+
**Step 1: Verify state.js loads correctly**
|
|
464
|
+
|
|
465
|
+
```bash
|
|
466
|
+
node -e "import('./src/lib/state.js').then(m => console.log('state.js OK'))"
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Expected: `state.js OK`
|
|
470
|
+
|
|
471
|
+
**Step 2: Verify hooks load correctly**
|
|
472
|
+
|
|
473
|
+
```bash
|
|
474
|
+
node -e "import('./hooks/prompt-capture.js').catch(() => console.log('prompt-capture.js OK'))"
|
|
475
|
+
node -e "import('./hooks/codex-capture.js').catch(() => console.log('codex-capture.js OK'))"
|
|
476
|
+
node -e "import('./hooks/gemini-capture.js').catch(() => console.log('gemini-capture.js OK'))"
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Expected: Each prints OK (the catch is because they exit on no input)
|
|
480
|
+
|
|
481
|
+
**Step 3: Verify setup command loads**
|
|
482
|
+
|
|
483
|
+
```bash
|
|
484
|
+
node -e "import('./src/commands/setup.js').then(m => console.log('setup.js OK'))"
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Expected: `setup.js OK`
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## Summary
|
|
492
|
+
|
|
493
|
+
| Task | Description |
|
|
494
|
+
|------|-------------|
|
|
495
|
+
| 1 | Create `src/lib/state.js` for Codex deduplication |
|
|
496
|
+
| 2 | Rewrite `hooks/prompt-capture.js` for UserPromptSubmit |
|
|
497
|
+
| 3 | Update `hooks/codex-capture.js` with deduplication |
|
|
498
|
+
| 4 | Verify `hooks/gemini-capture.js` (no changes needed) |
|
|
499
|
+
| 5 | Update `src/commands/setup.js` for new hook type |
|
|
500
|
+
| 6 | Bump version to 0.5.0 |
|
|
501
|
+
| 7 | Test all modules load correctly |
|
package/hooks/codex-capture.js
CHANGED
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* - thread-id: session identifier
|
|
9
9
|
* - turn-id: turn identifier
|
|
10
10
|
* - cwd: working directory
|
|
11
|
-
* - input-messages: array of user messages
|
|
11
|
+
* - input-messages: array of user messages (accumulates over session)
|
|
12
12
|
* - last-assistant-message: final assistant response
|
|
13
|
+
*
|
|
14
|
+
* This hook captures ALL new user messages since last capture using
|
|
15
|
+
* state tracking to avoid duplicates.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
import { capturePrompt } from '../src/lib/api.js';
|
|
@@ -17,18 +20,29 @@ import { getFullContext } from '../src/lib/context.js';
|
|
|
17
20
|
import { isLoggedIn, isVaultAvailable } from '../src/lib/config.js';
|
|
18
21
|
import { encryptPrompt } from '../src/lib/crypto.js';
|
|
19
22
|
import { requireVaultKey } from '../src/lib/keychain.js';
|
|
23
|
+
import { getLastCapturedIndex, saveLastCapturedIndex, cleanupStaleThreads } from '../src/lib/state.js';
|
|
24
|
+
|
|
25
|
+
function extractContent(message) {
|
|
26
|
+
if (typeof message === 'string') {
|
|
27
|
+
return message;
|
|
28
|
+
}
|
|
29
|
+
if (message.content) {
|
|
30
|
+
return Array.isArray(message.content)
|
|
31
|
+
? message.content.filter(c => c.type === 'text').map(c => c.text).join('\n')
|
|
32
|
+
: message.content;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
20
36
|
|
|
21
37
|
async function main() {
|
|
22
38
|
// Codex passes JSON as first argument
|
|
23
39
|
const jsonArg = process.argv[2];
|
|
24
40
|
|
|
25
41
|
if (!jsonArg) {
|
|
26
|
-
// No argument, nothing to do
|
|
27
42
|
process.exit(0);
|
|
28
43
|
}
|
|
29
44
|
|
|
30
45
|
if (!isLoggedIn()) {
|
|
31
|
-
// Silently exit if not logged in
|
|
32
46
|
process.exit(0);
|
|
33
47
|
}
|
|
34
48
|
|
|
@@ -44,61 +58,59 @@ async function main() {
|
|
|
44
58
|
process.exit(0);
|
|
45
59
|
}
|
|
46
60
|
|
|
47
|
-
// Extract the user's input messages
|
|
48
|
-
// Codex passes input-messages as an array of strings
|
|
49
61
|
const inputMessages = event['input-messages'] || [];
|
|
50
62
|
if (inputMessages.length === 0) {
|
|
51
63
|
process.exit(0);
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (!firstMessage) {
|
|
66
|
+
const threadId = event['thread-id'];
|
|
67
|
+
if (!threadId) {
|
|
57
68
|
process.exit(0);
|
|
58
69
|
}
|
|
59
70
|
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} else {
|
|
71
|
+
// Clean up stale threads periodically
|
|
72
|
+
cleanupStaleThreads();
|
|
73
|
+
|
|
74
|
+
// Get last captured index to avoid duplicates
|
|
75
|
+
const lastIndex = getLastCapturedIndex(threadId);
|
|
76
|
+
const newMessages = inputMessages.slice(lastIndex);
|
|
77
|
+
|
|
78
|
+
if (newMessages.length === 0) {
|
|
69
79
|
process.exit(0);
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
|
|
82
|
+
const vaultKey = await requireVaultKey({ silent: true });
|
|
83
|
+
if (!vaultKey) {
|
|
73
84
|
process.exit(0);
|
|
74
85
|
}
|
|
75
86
|
|
|
76
|
-
// Build context
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
// Override working directory if provided
|
|
87
|
+
// Build base context once
|
|
88
|
+
const baseContext = getFullContext('codex');
|
|
80
89
|
if (event.cwd) {
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Add session info
|
|
85
|
-
if (event['thread-id']) {
|
|
86
|
-
context.session_id = event['thread-id'];
|
|
90
|
+
baseContext.working_directory = event.cwd;
|
|
87
91
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
baseContext.session_id = threadId;
|
|
93
|
+
|
|
94
|
+
// Capture each new message
|
|
95
|
+
for (const message of newMessages) {
|
|
96
|
+
const content = extractContent(message);
|
|
97
|
+
if (!content || !content.trim()) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { encrypted_content, content_iv } = encryptPrompt(content.trim(), vaultKey);
|
|
102
|
+
|
|
103
|
+
await capturePrompt({
|
|
104
|
+
...baseContext,
|
|
105
|
+
encrypted_content,
|
|
106
|
+
content_iv
|
|
107
|
+
});
|
|
92
108
|
}
|
|
93
|
-
const { encrypted_content, content_iv } = encryptPrompt(content.trim(), vaultKey);
|
|
94
109
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
encrypted_content,
|
|
98
|
-
content_iv
|
|
99
|
-
});
|
|
110
|
+
// Update state with new index
|
|
111
|
+
saveLastCapturedIndex(threadId, inputMessages.length);
|
|
100
112
|
|
|
101
|
-
} catch
|
|
113
|
+
} catch {
|
|
102
114
|
// Fail silently to not disrupt Codex
|
|
103
115
|
process.exit(0);
|
|
104
116
|
}
|
package/hooks/prompt-capture.js
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Claude Code
|
|
4
|
+
* Claude Code UserPromptSubmit hook for capturing prompts to PromptCellar.
|
|
5
5
|
*
|
|
6
|
-
* This script is called by Claude Code
|
|
7
|
-
*
|
|
8
|
-
* - transcript_path: path to the session JSONL transcript
|
|
6
|
+
* This script is called by Claude Code on every user prompt submission.
|
|
7
|
+
* UserPromptSubmit hooks receive JSON via stdin with:
|
|
9
8
|
* - session_id: session identifier
|
|
9
|
+
* - prompt: the user's prompt text
|
|
10
10
|
* - cwd: working directory
|
|
11
|
-
*
|
|
12
|
-
* It reads the transcript file and extracts the initial user prompt to capture.
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
|
-
import { readFileSync, existsSync } from 'fs';
|
|
16
13
|
import { capturePrompt } from '../src/lib/api.js';
|
|
17
14
|
import { getFullContext } from '../src/lib/context.js';
|
|
18
15
|
import { isLoggedIn, isVaultAvailable } from '../src/lib/config.js';
|
|
@@ -48,37 +45,17 @@ async function main() {
|
|
|
48
45
|
}
|
|
49
46
|
|
|
50
47
|
const event = JSON.parse(input);
|
|
51
|
-
const transcriptPath = event.transcript_path;
|
|
52
48
|
|
|
53
|
-
|
|
49
|
+
// UserPromptSubmit provides the prompt directly
|
|
50
|
+
if (!event.prompt) {
|
|
54
51
|
process.exit(0);
|
|
55
52
|
}
|
|
56
53
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// Parse JSONL format
|
|
61
|
-
const messages = [];
|
|
62
|
-
for (const line of lines) {
|
|
63
|
-
try {
|
|
64
|
-
const entry = JSON.parse(line);
|
|
65
|
-
if (entry.type === 'human' && entry.message?.content) {
|
|
66
|
-
messages.push({
|
|
67
|
-
content: entry.message.content,
|
|
68
|
-
timestamp: entry.timestamp
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
} catch {
|
|
72
|
-
// Skip invalid JSON lines
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (messages.length === 0) {
|
|
54
|
+
const promptContent = event.prompt.trim();
|
|
55
|
+
if (!promptContent) {
|
|
77
56
|
process.exit(0);
|
|
78
57
|
}
|
|
79
58
|
|
|
80
|
-
// Capture the first user message (initial prompt)
|
|
81
|
-
const initialPrompt = messages[0];
|
|
82
59
|
const context = getFullContext('claude-code');
|
|
83
60
|
|
|
84
61
|
// Override with event data if available
|
|
@@ -89,15 +66,6 @@ async function main() {
|
|
|
89
66
|
context.session_id = event.session_id;
|
|
90
67
|
}
|
|
91
68
|
|
|
92
|
-
// Content can be a string or an array of content blocks
|
|
93
|
-
let promptContent = initialPrompt.content;
|
|
94
|
-
if (Array.isArray(promptContent)) {
|
|
95
|
-
promptContent = promptContent
|
|
96
|
-
.filter(block => block.type === 'text')
|
|
97
|
-
.map(block => block.text)
|
|
98
|
-
.join('\n');
|
|
99
|
-
}
|
|
100
|
-
|
|
101
69
|
const vaultKey = await requireVaultKey({ silent: true });
|
|
102
70
|
if (!vaultKey) {
|
|
103
71
|
process.exit(0);
|
|
@@ -107,8 +75,7 @@ async function main() {
|
|
|
107
75
|
await capturePrompt({
|
|
108
76
|
...context,
|
|
109
77
|
encrypted_content,
|
|
110
|
-
content_iv
|
|
111
|
-
captured_at: initialPrompt.timestamp
|
|
78
|
+
content_iv
|
|
112
79
|
});
|
|
113
80
|
|
|
114
81
|
} catch {
|
package/package.json
CHANGED
package/src/commands/setup.js
CHANGED
|
@@ -58,8 +58,8 @@ function saveClaudeSettings(settings) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
function isClaudeHookInstalled(settings) {
|
|
61
|
-
const
|
|
62
|
-
return
|
|
61
|
+
const matchers = settings.hooks?.UserPromptSubmit || [];
|
|
62
|
+
return matchers.some(matcher =>
|
|
63
63
|
matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
|
|
64
64
|
);
|
|
65
65
|
}
|
|
@@ -184,21 +184,22 @@ async function setupClaudeCode() {
|
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
// Remove existing hook matchers that contain our hook
|
|
187
|
-
settings.hooks.
|
|
187
|
+
settings.hooks.UserPromptSubmit = (settings.hooks.UserPromptSubmit || []).filter(matcher =>
|
|
188
188
|
!matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
|
|
189
189
|
);
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
// Ensure hooks.
|
|
192
|
+
// Ensure hooks.UserPromptSubmit exists
|
|
193
193
|
if (!settings.hooks) {
|
|
194
194
|
settings.hooks = {};
|
|
195
195
|
}
|
|
196
|
-
if (!settings.hooks.
|
|
197
|
-
settings.hooks.
|
|
196
|
+
if (!settings.hooks.UserPromptSubmit) {
|
|
197
|
+
settings.hooks.UserPromptSubmit = [];
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
// Add the
|
|
201
|
-
settings.hooks.
|
|
200
|
+
// Add the UserPromptSubmit hook
|
|
201
|
+
settings.hooks.UserPromptSubmit.push({
|
|
202
|
+
matcher: '*',
|
|
202
203
|
hooks: [{
|
|
203
204
|
type: 'command',
|
|
204
205
|
command: 'pc-capture'
|
|
@@ -315,7 +316,7 @@ export async function unsetup() {
|
|
|
315
316
|
// Remove Claude hook
|
|
316
317
|
const claudeSettings = getClaudeSettings();
|
|
317
318
|
if (isClaudeHookInstalled(claudeSettings)) {
|
|
318
|
-
claudeSettings.hooks.
|
|
319
|
+
claudeSettings.hooks.UserPromptSubmit = (claudeSettings.hooks.UserPromptSubmit || []).filter(matcher =>
|
|
319
320
|
!matcher.hooks?.some(hook => hook.command?.includes(HOOK_SCRIPT_NAME))
|
|
320
321
|
);
|
|
321
322
|
saveClaudeSettings(claudeSettings);
|
package/src/lib/state.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import Conf from 'conf';
|
|
2
|
+
|
|
3
|
+
const state = new Conf({
|
|
4
|
+
projectName: 'promptcellar',
|
|
5
|
+
configName: 'capture-state',
|
|
6
|
+
schema: {
|
|
7
|
+
threads: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
default: {}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the last captured index for a thread.
|
|
18
|
+
* @param {string} threadId
|
|
19
|
+
* @returns {number}
|
|
20
|
+
*/
|
|
21
|
+
export function getLastCapturedIndex(threadId) {
|
|
22
|
+
const threads = state.get('threads');
|
|
23
|
+
return threads[threadId]?.lastIndex || 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Save the last captured index for a thread.
|
|
28
|
+
* @param {string} threadId
|
|
29
|
+
* @param {number} lastIndex
|
|
30
|
+
*/
|
|
31
|
+
export function saveLastCapturedIndex(threadId, lastIndex) {
|
|
32
|
+
const threads = state.get('threads');
|
|
33
|
+
threads[threadId] = {
|
|
34
|
+
lastIndex,
|
|
35
|
+
updatedAt: Date.now()
|
|
36
|
+
};
|
|
37
|
+
state.set('threads', threads);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Clean up stale thread entries older than maxAgeMs.
|
|
42
|
+
* @param {number} maxAgeMs - Default 24 hours
|
|
43
|
+
*/
|
|
44
|
+
export function cleanupStaleThreads(maxAgeMs = MAX_AGE_MS) {
|
|
45
|
+
const threads = state.get('threads');
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
let changed = false;
|
|
48
|
+
|
|
49
|
+
for (const [threadId, data] of Object.entries(threads)) {
|
|
50
|
+
if (now - data.updatedAt > maxAgeMs) {
|
|
51
|
+
delete threads[threadId];
|
|
52
|
+
changed = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (changed) {
|
|
57
|
+
state.set('threads', threads);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default { getLastCapturedIndex, saveLastCapturedIndex, cleanupStaleThreads };
|