@claudemini/ses-cli 1.4.3

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,315 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * AI Summarization module
5
+ * Automatically generates AI-powered summaries of sessions using LLM APIs
6
+ * Supports OpenAI and Anthropic APIs
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { getProjectRoot } from './config.js';
12
+ import { SUMMARY_SYSTEM_PROMPT, buildSummarizePrompt } from './prompts.js';
13
+
14
+ // Default configuration
15
+ const DEFAULT_CONFIG = {
16
+ provider: 'openai', // or 'anthropic'
17
+ model: 'gpt-4o-mini',
18
+ max_tokens: 1000,
19
+ temperature: 0.7,
20
+ openai_base_url: 'https://api.openai.com/v1',
21
+ };
22
+
23
+ /**
24
+ * Get API configuration from environment or config file
25
+ */
26
+ export function getApiConfig(projectRoot) {
27
+ const config = { ...DEFAULT_CONFIG };
28
+
29
+ // Check for project config
30
+ const configFile = join(projectRoot, '.ses-logs', 'config.json');
31
+ if (existsSync(configFile)) {
32
+ try {
33
+ const fileConfig = JSON.parse(readFileSync(configFile, 'utf-8'));
34
+ Object.assign(config, fileConfig);
35
+ } catch {
36
+ // Use defaults
37
+ }
38
+ }
39
+
40
+ // Environment variables override file config
41
+ if (process.env.OPENAI_API_KEY) {
42
+ config.provider = 'openai';
43
+ config.api_key = process.env.OPENAI_API_KEY;
44
+ } else if (process.env.ANTHROPIC_API_KEY) {
45
+ config.provider = 'anthropic';
46
+ config.api_key = process.env.ANTHROPIC_API_KEY;
47
+ }
48
+
49
+ if (process.env.OPENAI_BASE_URL) {
50
+ config.openai_base_url = process.env.OPENAI_BASE_URL;
51
+ }
52
+ if (process.env.OPENAI_ENDPOINT) {
53
+ config.openai_endpoint = process.env.OPENAI_ENDPOINT;
54
+ }
55
+ // Universal override for all providers.
56
+ if (process.env.AI_MODEL) {
57
+ config.model = process.env.AI_MODEL;
58
+ }
59
+
60
+ // Provider-specific overrides take precedence when applicable.
61
+ if (config.provider === 'openai' && process.env.OPENAI_MODEL) {
62
+ config.model = process.env.OPENAI_MODEL;
63
+ }
64
+ if (config.provider === 'anthropic' && process.env.ANTHROPIC_MODEL) {
65
+ config.model = process.env.ANTHROPIC_MODEL;
66
+ }
67
+
68
+ return config;
69
+ }
70
+
71
+ /**
72
+ * Extract relevant context from session for summarization
73
+ */
74
+ function extractContext(sessionDir) {
75
+ const context = {
76
+ prompts: [],
77
+ changes: [],
78
+ tools: {},
79
+ errors: [],
80
+ summary: null,
81
+ };
82
+
83
+ // Read summary.json
84
+ const summaryFile = join(sessionDir, 'summary.json');
85
+ if (existsSync(summaryFile)) {
86
+ try {
87
+ const summary = JSON.parse(readFileSync(summaryFile, 'utf-8'));
88
+ context.summary = summary;
89
+ context.prompts = summary.prompts || [];
90
+ context.tools = summary.activity?.tools || {};
91
+ context.errors = summary.activity?.errors || [];
92
+ context.changes = summary.changes?.files || [];
93
+ } catch {
94
+ // Best effort
95
+ }
96
+ }
97
+
98
+ // Read prompts.txt
99
+ const promptsFile = join(sessionDir, 'prompts.txt');
100
+ if (existsSync(promptsFile)) {
101
+ try {
102
+ context.prompts_text = readFileSync(promptsFile, 'utf-8').slice(0, 3000);
103
+ } catch {
104
+ // Best effort
105
+ }
106
+ }
107
+
108
+ // Read context.md
109
+ const contextFile = join(sessionDir, 'context.md');
110
+ if (existsSync(contextFile)) {
111
+ try {
112
+ context.context_md = readFileSync(contextFile, 'utf-8').slice(0, 2000);
113
+ } catch {
114
+ // Best effort
115
+ }
116
+ }
117
+
118
+ return context;
119
+ }
120
+
121
+ /**
122
+ * Call OpenAI API
123
+ */
124
+ function resolveOpenAIEndpoint(config) {
125
+ const explicitEndpoint = (config.openai_endpoint || '').trim();
126
+ if (explicitEndpoint) {
127
+ return explicitEndpoint;
128
+ }
129
+
130
+ const baseUrl = String(config.openai_base_url || DEFAULT_CONFIG.openai_base_url).trim().replace(/\/+$/, '');
131
+ if (baseUrl.endsWith('/chat/completions')) {
132
+ return baseUrl;
133
+ }
134
+ return `${baseUrl}/chat/completions`;
135
+ }
136
+
137
+ async function callOpenAI(apiKey, endpoint, model, prompt, maxTokens, temperature) {
138
+ const response = await fetch(endpoint, {
139
+ method: 'POST',
140
+ headers: {
141
+ 'Content-Type': 'application/json',
142
+ 'Authorization': `Bearer ${apiKey}`,
143
+ },
144
+ body: JSON.stringify({
145
+ model,
146
+ messages: [
147
+ { role: 'system', content: SUMMARY_SYSTEM_PROMPT },
148
+ { role: 'user', content: prompt }
149
+ ],
150
+ max_tokens: maxTokens,
151
+ temperature,
152
+ }),
153
+ });
154
+
155
+ if (!response.ok) {
156
+ const error = await response.text();
157
+ throw new Error(`OpenAI API error: ${response.status} - ${error}`);
158
+ }
159
+
160
+ const data = await response.json();
161
+ const msg = data.choices[0].message;
162
+ // Some OpenAI-compatible providers return reasoning output in `reasoning_content`.
163
+ return msg.content || msg.reasoning_content || '';
164
+ }
165
+
166
+ /**
167
+ * Call Anthropic API
168
+ */
169
+ async function callAnthropic(apiKey, model, prompt, maxTokens, temperature) {
170
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
171
+ method: 'POST',
172
+ headers: {
173
+ 'Content-Type': 'application/json',
174
+ 'x-api-key': apiKey,
175
+ 'anthropic-version': '2023-06-01',
176
+ },
177
+ body: JSON.stringify({
178
+ model,
179
+ max_tokens: maxTokens,
180
+ temperature,
181
+ messages: [
182
+ { role: 'user', content: prompt }
183
+ ],
184
+ }),
185
+ });
186
+
187
+ if (!response.ok) {
188
+ const error = await response.text();
189
+ throw new Error(`Anthropic API error: ${response.status} - ${error}`);
190
+ }
191
+
192
+ const data = await response.json();
193
+ return data.content[0].text;
194
+ }
195
+
196
+ /**
197
+ * Generate AI summary for a session
198
+ */
199
+ export async function summarizeSession(projectRoot, sessionId, sessionDir) {
200
+ const config = getApiConfig(projectRoot);
201
+
202
+ if (!config.api_key) {
203
+ return {
204
+ success: false,
205
+ reason: 'No API key configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.'
206
+ };
207
+ }
208
+
209
+ // Extract context from session
210
+ const context = extractContext(sessionDir);
211
+
212
+ // Build prompt
213
+ const prompt = buildSummarizePrompt(context);
214
+
215
+ try {
216
+ let summary;
217
+
218
+ if (config.provider === 'anthropic') {
219
+ summary = await callAnthropic(
220
+ config.api_key,
221
+ config.model || 'claude-3-haiku-20240307',
222
+ prompt,
223
+ config.max_tokens,
224
+ config.temperature
225
+ );
226
+ } else {
227
+ const openaiEndpoint = resolveOpenAIEndpoint(config);
228
+ summary = await callOpenAI(
229
+ config.api_key,
230
+ openaiEndpoint,
231
+ config.model || 'gpt-4o-mini',
232
+ prompt,
233
+ config.max_tokens,
234
+ config.temperature
235
+ );
236
+ }
237
+
238
+ // Save summary
239
+ const aiSummaryFile = join(sessionDir, 'ai-summary.md');
240
+ writeFileSync(aiSummaryFile, summary);
241
+
242
+ // Update state
243
+ const stateFile = join(sessionDir, 'state.json');
244
+ if (existsSync(stateFile)) {
245
+ try {
246
+ const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
247
+ state.ai_summary = {
248
+ provider: config.provider,
249
+ model: config.model,
250
+ generated_at: new Date().toISOString(),
251
+ };
252
+ writeFileSync(stateFile, JSON.stringify(state, null, 2));
253
+ } catch {
254
+ // Best effort
255
+ }
256
+ }
257
+
258
+ return {
259
+ success: true,
260
+ summary,
261
+ provider: config.provider,
262
+ model: config.model,
263
+ };
264
+ } catch (error) {
265
+ return {
266
+ success: false,
267
+ reason: error.message
268
+ };
269
+ }
270
+ }
271
+
272
+ /**
273
+ * CLI command for manual summarization
274
+ */
275
+ export default async function summarize(args) {
276
+ const projectRoot = getProjectRoot();
277
+ const sessionId = args[0];
278
+
279
+ if (!sessionId) {
280
+ console.log('Usage: ses summarize <session-id>');
281
+ console.log('\nEnvironment variables:');
282
+ console.log(' OPENAI_API_KEY # Use OpenAI for summarization');
283
+ console.log(' OPENAI_BASE_URL # OpenAI-compatible base URL (e.g. https://api.openai.com/v1)');
284
+ console.log(' OPENAI_ENDPOINT # Full OpenAI-compatible endpoint (overrides OPENAI_BASE_URL)');
285
+ console.log(' AI_MODEL # Universal model override (applies to all providers)');
286
+ console.log(' OPENAI_MODEL # OpenAI-only model override');
287
+ console.log(' ANTHROPIC_MODEL # Anthropic-only model override');
288
+ console.log(' ANTHROPIC_API_KEY # Use Anthropic for summarization');
289
+ console.log('\nConfiguration (.ses-logs/config.json):');
290
+ console.log(` {"provider": "openai", "model": "gpt-4o-mini", "openai_base_url": "https://api.openai.com/v1"}`);
291
+ process.exit(1);
292
+ }
293
+
294
+ const sessionDir = join(projectRoot, '.ses-logs', sessionId);
295
+
296
+ if (!existsSync(sessionDir)) {
297
+ console.error(`Session not found: ${sessionId}`);
298
+ process.exit(1);
299
+ }
300
+
301
+ console.log(`🤖 Generating AI summary for session: ${sessionId}\n`);
302
+
303
+ const result = await summarizeSession(projectRoot, sessionId, sessionDir);
304
+
305
+ if (result.success) {
306
+ console.log('✅ AI Summary generated!\n');
307
+ console.log(result.summary);
308
+ console.log(`\n---`);
309
+ console.log(`Provider: ${result.provider}`);
310
+ console.log(`Model: ${result.model}`);
311
+ } else {
312
+ console.error('❌ Failed to generate summary:', result.reason);
313
+ process.exit(1);
314
+ }
315
+ }
package/lib/view.js ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, existsSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { getProjectRoot, getLogDir } from './config.js';
6
+
7
+ export default async function view(args) {
8
+ const sessionId = args[0];
9
+ if (!sessionId) {
10
+ console.error('Usage: ses view <session-id>');
11
+ process.exit(1);
12
+ }
13
+
14
+ const logDir = getLogDir(getProjectRoot());
15
+ const sessionDir = join(logDir, sessionId);
16
+ if (!existsSync(sessionDir)) {
17
+ console.error(`Session not found: ${sessionId}`);
18
+ process.exit(1);
19
+ }
20
+
21
+ // Show summary.txt (contains semantic report)
22
+ const summaryFile = join(sessionDir, 'summary.txt');
23
+ if (existsSync(summaryFile)) {
24
+ console.log(readFileSync(summaryFile, 'utf-8'));
25
+ }
26
+
27
+ // Show summary.json v2 data if --json flag
28
+ if (args.includes('--json')) {
29
+ const jsonFile = join(sessionDir, 'summary.json');
30
+ if (existsSync(jsonFile)) {
31
+ console.log('\n--- summary.json ---');
32
+ console.log(readFileSync(jsonFile, 'utf-8'));
33
+ }
34
+ }
35
+
36
+ // Show shadow branch info
37
+ const stateFile = join(sessionDir, 'state.json');
38
+ if (existsSync(stateFile)) {
39
+ try {
40
+ const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
41
+ if (state.shadow_branch) {
42
+ console.log(`\nShadow branch: ${state.shadow_branch}`);
43
+ console.log(` git log ${state.shadow_branch} --oneline`);
44
+ console.log(` git show ${state.shadow_branch}:.ses-logs/${sessionId}/summary.json`);
45
+ }
46
+ } catch { /* ignore */ }
47
+ }
48
+
49
+ console.log(`\nFull logs: ${sessionDir}`);
50
+ }
package/lib/webhook.js ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Webhook support for ses-cli.
3
+ * Fire-and-forget notifications for session.ended and review.completed events.
4
+ */
5
+
6
+ import { readFileSync, existsSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { createHmac } from 'crypto';
9
+ import { request as httpsRequest } from 'https';
10
+ import { request as httpRequest } from 'http';
11
+
12
+ /**
13
+ * Load webhook configuration from .ses-logs/config.json + environment variables.
14
+ * Priority (highest wins): process.env > config.json env > config.json webhooks
15
+ */
16
+ export function loadWebhookConfig(projectRoot) {
17
+ let fileConfig = {};
18
+ let configEnv = {};
19
+ const configPath = join(projectRoot, '.ses-logs', 'config.json');
20
+ if (existsSync(configPath)) {
21
+ try {
22
+ const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
23
+ fileConfig = raw.webhooks || {};
24
+ if (raw.env && typeof raw.env === 'object') {
25
+ configEnv = raw.env;
26
+ }
27
+ } catch { /* ignore malformed config */ }
28
+ }
29
+
30
+ // Resolve: process.env > config.json env > config.json webhooks field
31
+ const env = (key) => {
32
+ if (key in process.env) return process.env[key];
33
+ if (key in configEnv) return configEnv[key];
34
+ return '';
35
+ };
36
+
37
+ const url = env('SES_WEBHOOK_URL') || fileConfig.url;
38
+ if (!url) return null;
39
+
40
+ const envEvents = env('SES_WEBHOOK_EVENTS');
41
+ const events = envEvents
42
+ ? envEvents.split(',').map(e => e.trim()).filter(Boolean)
43
+ : (Array.isArray(fileConfig.events) ? fileConfig.events : []);
44
+
45
+ return {
46
+ url,
47
+ events,
48
+ secret: env('SES_WEBHOOK_SECRET') || fileConfig.secret || '',
49
+ auth_token: env('SES_WEBHOOK_AUTH_TOKEN') || fileConfig.auth_token || '',
50
+ headers: fileConfig.headers || {},
51
+ timeout_ms: fileConfig.timeout_ms || 5000,
52
+ retry: typeof fileConfig.retry === 'number' ? fileConfig.retry : 1,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Compute HMAC-SHA256 signature in GitHub-compatible format.
58
+ */
59
+ function computeSignature(secret, body) {
60
+ const hmac = createHmac('sha256', secret);
61
+ hmac.update(body);
62
+ return `sha256=${hmac.digest('hex')}`;
63
+ }
64
+
65
+ /**
66
+ * Send a single HTTP POST request. Returns a promise that resolves/rejects.
67
+ */
68
+ function httpPost(url, headers, body, timeoutMs) {
69
+ return new Promise((resolve, reject) => {
70
+ const parsed = new URL(url);
71
+ const isHttps = parsed.protocol === 'https:';
72
+ const reqFn = isHttps ? httpsRequest : httpRequest;
73
+
74
+ const options = {
75
+ hostname: parsed.hostname,
76
+ port: parsed.port || (isHttps ? 443 : 80),
77
+ path: parsed.pathname + parsed.search,
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/json',
81
+ 'Content-Length': Buffer.byteLength(body),
82
+ ...headers,
83
+ },
84
+ timeout: timeoutMs,
85
+ };
86
+
87
+ const req = reqFn(options, (res) => {
88
+ let data = '';
89
+ res.on('data', (chunk) => { data += chunk; });
90
+ res.on('end', () => {
91
+ if (res.statusCode >= 200 && res.statusCode < 300) {
92
+ resolve({ status: res.statusCode, body: data });
93
+ } else {
94
+ reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
95
+ }
96
+ });
97
+ });
98
+
99
+ req.on('timeout', () => {
100
+ req.destroy();
101
+ reject(new Error(`Request timed out after ${timeoutMs}ms`));
102
+ });
103
+ req.on('error', reject);
104
+ req.write(body);
105
+ req.end();
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Send webhook for a single config entry. Supports retry.
111
+ */
112
+ export async function sendWebhook(config, event, payload) {
113
+ const body = JSON.stringify({
114
+ event,
115
+ timestamp: new Date().toISOString(),
116
+ payload,
117
+ });
118
+
119
+ const headers = { ...config.headers };
120
+
121
+ // Auth: HMAC signature or Bearer token (mutually exclusive, HMAC preferred)
122
+ if (config.secret) {
123
+ headers['X-Signature-256'] = computeSignature(config.secret, body);
124
+ } else if (config.auth_token) {
125
+ headers['Authorization'] = `Bearer ${config.auth_token}`;
126
+ }
127
+
128
+ const maxAttempts = (config.retry || 0) + 1;
129
+ let lastError;
130
+
131
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
132
+ try {
133
+ await httpPost(config.url, headers, body, config.timeout_ms || 5000);
134
+ return; // success
135
+ } catch (err) {
136
+ lastError = err;
137
+ if (attempt < maxAttempts) {
138
+ // Brief delay before retry
139
+ await new Promise(r => setTimeout(r, 500 * attempt));
140
+ }
141
+ }
142
+ }
143
+
144
+ throw lastError;
145
+ }
146
+
147
+ /**
148
+ * Main entry point: load config, filter by event, dispatch webhook.
149
+ * Non-blocking — logs warnings to stderr, never throws.
150
+ */
151
+ export async function dispatchWebhook(projectRoot, event, payload) {
152
+ try {
153
+ const config = loadWebhookConfig(projectRoot);
154
+ if (!config) return;
155
+
156
+ // If events list is configured, only fire for matching events
157
+ if (config.events.length > 0 && !config.events.includes(event)) return;
158
+
159
+ await sendWebhook(config, event, payload);
160
+ } catch (err) {
161
+ process.stderr.write(`[ses-cli] webhook warning: ${err.message}\n`);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * CLI command: ses webhook
167
+ * Shows current webhook configuration status.
168
+ */
169
+ export default async function webhook(args) {
170
+ const { getProjectRoot } = await import('./config.js');
171
+ const projectRoot = getProjectRoot();
172
+ const config = loadWebhookConfig(projectRoot);
173
+
174
+ if (args.includes('--help') || args.includes('-h')) {
175
+ console.log('Usage: ses webhook [options]');
176
+ console.log('');
177
+ console.log('Show current webhook configuration.');
178
+ console.log('');
179
+ console.log('Options:');
180
+ console.log(' --test Send a test webhook ping');
181
+ console.log(' --help Show this help');
182
+ console.log('');
183
+ console.log('Configuration (highest priority first):');
184
+ console.log(' 1. Environment variables:');
185
+ console.log(' SES_WEBHOOK_URL Webhook endpoint URL');
186
+ console.log(' SES_WEBHOOK_SECRET HMAC-SHA256 signing secret');
187
+ console.log(' SES_WEBHOOK_AUTH_TOKEN Bearer token');
188
+ console.log(' SES_WEBHOOK_EVENTS Comma-separated event list');
189
+ console.log(' 2. .ses-logs/config.json "env" field');
190
+ console.log(' 3. .ses-logs/config.json "webhooks" field');
191
+ return 0;
192
+ }
193
+
194
+ if (!config) {
195
+ console.log('No webhook configured.');
196
+ console.log('');
197
+ console.log('Set SES_WEBHOOK_URL or add "webhooks" to .ses-logs/config.json');
198
+ return 0;
199
+ }
200
+
201
+ console.log('Webhook configuration:');
202
+ console.log(` URL: ${config.url}`);
203
+ console.log(` Events: ${config.events.length > 0 ? config.events.join(', ') : '(all)'}`);
204
+ console.log(` Auth: ${config.secret ? 'HMAC-SHA256' : config.auth_token ? 'Bearer token' : 'none'}`);
205
+ console.log(` Timeout: ${config.timeout_ms}ms`);
206
+ console.log(` Retry: ${config.retry}`);
207
+ if (Object.keys(config.headers).length > 0) {
208
+ console.log(` Headers: ${Object.keys(config.headers).join(', ')}`);
209
+ }
210
+
211
+ if (args.includes('--test')) {
212
+ console.log('');
213
+ console.log('Sending test ping...');
214
+ try {
215
+ await sendWebhook(config, 'ping', { message: 'ses-cli webhook test' });
216
+ console.log('OK — webhook delivered successfully.');
217
+ } catch (err) {
218
+ console.error(`Failed: ${err.message}`);
219
+ return 1;
220
+ }
221
+ }
222
+
223
+ return 0;
224
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@claudemini/ses-cli",
3
+ "version": "1.4.3",
4
+ "description": "Session-based Hook Intelligence Tracker for human-AI coding sessions",
5
+ "type": "module",
6
+ "bin": {
7
+ "ses": "./bin/ses.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18.0.0"
16
+ },
17
+ "scripts": {
18
+ "test": "node --check bin/ses.js && for f in lib/*.js; do node --check \"$f\"; done"
19
+ },
20
+ "keywords": [
21
+ "claude-code",
22
+ "gemini-cli",
23
+ "cursor",
24
+ "ai-coding",
25
+ "hooks",
26
+ "session-tracking",
27
+ "code-review",
28
+ "checkpoint"
29
+ ],
30
+ "homepage": "",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": ""
34
+ },
35
+ "author": "",
36
+ "license": "",
37
+ "dependencies": {
38
+ "goatchain": "^0.0.29"
39
+ },
40
+ "devDependencies": {}
41
+ }