@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 |
@@ -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
- // Get the first message as the prompt
55
- const firstMessage = inputMessages[0];
56
- if (!firstMessage) {
66
+ const threadId = event['thread-id'];
67
+ if (!threadId) {
57
68
  process.exit(0);
58
69
  }
59
70
 
60
- // Handle both string format (current Codex) and object format (future-proofing)
61
- let content;
62
- if (typeof firstMessage === 'string') {
63
- content = firstMessage;
64
- } else if (firstMessage.content) {
65
- content = Array.isArray(firstMessage.content)
66
- ? firstMessage.content.filter(c => c.type === 'text').map(c => c.text).join('\n')
67
- : firstMessage.content;
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
- if (!content.trim()) {
82
+ const vaultKey = await requireVaultKey({ silent: true });
83
+ if (!vaultKey) {
73
84
  process.exit(0);
74
85
  }
75
86
 
76
- // Build context
77
- const context = getFullContext('codex');
78
-
79
- // Override working directory if provided
87
+ // Build base context once
88
+ const baseContext = getFullContext('codex');
80
89
  if (event.cwd) {
81
- context.working_directory = event.cwd;
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
- const vaultKey = await requireVaultKey({ silent: true });
90
- if (!vaultKey) {
91
- process.exit(0);
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
- await capturePrompt({
96
- ...context,
97
- encrypted_content,
98
- content_iv
99
- });
110
+ // Update state with new index
111
+ saveLastCapturedIndex(threadId, inputMessages.length);
100
112
 
101
- } catch (error) {
113
+ } catch {
102
114
  // Fail silently to not disrupt Codex
103
115
  process.exit(0);
104
116
  }
@@ -1,18 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Claude Code Stop hook for capturing prompts to PromptCellar.
4
+ * Claude Code UserPromptSubmit hook for capturing prompts to PromptCellar.
5
5
  *
6
- * This script is called by Claude Code when a session ends.
7
- * Claude Code Stop hooks receive JSON via stdin with:
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
- if (!transcriptPath || !existsSync(transcriptPath)) {
49
+ // UserPromptSubmit provides the prompt directly
50
+ if (!event.prompt) {
54
51
  process.exit(0);
55
52
  }
56
53
 
57
- const content = readFileSync(transcriptPath, 'utf8');
58
- const lines = content.split('\n').filter(line => line.trim());
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptcellar/pc",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "CLI for PromptCellar - sync prompts between your terminal and the cloud",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -58,8 +58,8 @@ function saveClaudeSettings(settings) {
58
58
  }
59
59
 
60
60
  function isClaudeHookInstalled(settings) {
61
- const stopMatchers = settings.hooks?.Stop || [];
62
- return stopMatchers.some(matcher =>
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.Stop = (settings.hooks.Stop || []).filter(matcher =>
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.Stop exists
192
+ // Ensure hooks.UserPromptSubmit exists
193
193
  if (!settings.hooks) {
194
194
  settings.hooks = {};
195
195
  }
196
- if (!settings.hooks.Stop) {
197
- settings.hooks.Stop = [];
196
+ if (!settings.hooks.UserPromptSubmit) {
197
+ settings.hooks.UserPromptSubmit = [];
198
198
  }
199
199
 
200
- // Add the Stop hook (Stop hooks don't use matchers)
201
- settings.hooks.Stop.push({
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.Stop = (claudeSettings.hooks.Stop || []).filter(matcher =>
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);
@@ -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 };