@claudemini/shit-cli 1.3.0 → 1.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.
- package/README.md +126 -117
- package/bin/shit.js +1 -0
- package/lib/checkpoint.js +48 -26
- package/lib/checkpoints.js +3 -15
- package/lib/commit.js +7 -15
- package/lib/config.js +3 -1
- package/lib/disable.js +54 -18
- package/lib/doctor.js +17 -24
- package/lib/enable.js +34 -14
- package/lib/explain.js +43 -38
- package/lib/reset.js +8 -16
- package/lib/resume.js +32 -27
- package/lib/rewind.js +63 -38
- package/lib/session.js +6 -0
- package/lib/status.js +44 -19
- package/lib/summarize.js +331 -0
- package/package.json +21 -4
- package/.claude/settings.json +0 -81
- package/.claude/settings.local.json +0 -20
- package/COMPARISON.md +0 -92
- package/DESIGN_PHILOSOPHY.md +0 -138
- package/QUICKSTART.md +0 -109
package/lib/rewind.js
CHANGED
|
@@ -5,54 +5,71 @@
|
|
|
5
5
|
* Similar to 'entire rewind' - rollback to known good state
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
9
|
-
import { join } from 'path';
|
|
10
8
|
import { execSync } from 'child_process';
|
|
11
|
-
|
|
12
|
-
function findProjectRoot() {
|
|
13
|
-
let dir = process.cwd();
|
|
14
|
-
while (dir !== '/') {
|
|
15
|
-
if (existsSync(join(dir, '.git'))) {
|
|
16
|
-
return dir;
|
|
17
|
-
}
|
|
18
|
-
dir = join(dir, '..');
|
|
19
|
-
}
|
|
20
|
-
throw new Error('Not in a git repository');
|
|
21
|
-
}
|
|
9
|
+
import { getProjectRoot } from './config.js';
|
|
22
10
|
|
|
23
11
|
function git(cmd, cwd) {
|
|
24
12
|
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
|
|
25
13
|
}
|
|
26
14
|
|
|
15
|
+
function parseCheckpointRef(projectRoot, branch) {
|
|
16
|
+
const shadowMatch = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
|
|
17
|
+
if (shadowMatch) {
|
|
18
|
+
return {
|
|
19
|
+
type: 'shadow',
|
|
20
|
+
baseCommit: shadowMatch[1],
|
|
21
|
+
sessionShort: shadowMatch[2],
|
|
22
|
+
lookupKey: shadowMatch[2],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const checkpointMatch = branch.match(/^shit\/checkpoints\/v1\/(\d{4}-\d{2}-\d{2})-([a-f0-9]+)$/);
|
|
27
|
+
if (checkpointMatch) {
|
|
28
|
+
const message = git(`log ${branch} --format=%B -1`, projectRoot);
|
|
29
|
+
const linkedMatch = message.match(/@ ([a-f0-9]+)/);
|
|
30
|
+
const date = checkpointMatch[1];
|
|
31
|
+
const sessionShort = checkpointMatch[2];
|
|
32
|
+
return {
|
|
33
|
+
type: 'checkpoint',
|
|
34
|
+
baseCommit: linkedMatch ? linkedMatch[1] : null,
|
|
35
|
+
sessionShort,
|
|
36
|
+
lookupKey: `${date}-${sessionShort}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
function listCheckpoints(projectRoot) {
|
|
28
44
|
try {
|
|
29
|
-
|
|
30
|
-
const branches = git('branch --list "shit/*"', projectRoot)
|
|
45
|
+
const branches = git('branch --list "shit/checkpoints/v1/*" "shit/*"', projectRoot)
|
|
31
46
|
.split('\n')
|
|
32
47
|
.map(b => b.trim().replace(/^\*?\s*/, ''))
|
|
33
48
|
.filter(Boolean);
|
|
49
|
+
const uniqueBranches = [...new Set(branches)];
|
|
34
50
|
|
|
35
51
|
const checkpoints = [];
|
|
36
52
|
|
|
37
|
-
for (const branch of
|
|
53
|
+
for (const branch of uniqueBranches) {
|
|
38
54
|
try {
|
|
55
|
+
const parsed = parseCheckpointRef(projectRoot, branch);
|
|
56
|
+
if (!parsed) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
const log = git(`log ${branch} --oneline -1`, projectRoot);
|
|
40
61
|
const [commit, ...messageParts] = log.split(' ');
|
|
41
62
|
const message = messageParts.join(' ');
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
message,
|
|
53
|
-
timestamp: git(`log ${branch} --format=%ci -1`, projectRoot)
|
|
54
|
-
});
|
|
55
|
-
}
|
|
63
|
+
checkpoints.push({
|
|
64
|
+
branch,
|
|
65
|
+
commit,
|
|
66
|
+
baseCommit: parsed.baseCommit,
|
|
67
|
+
sessionShort: parsed.sessionShort,
|
|
68
|
+
lookupKey: parsed.lookupKey,
|
|
69
|
+
type: parsed.type,
|
|
70
|
+
message,
|
|
71
|
+
timestamp: git(`log ${branch} --format=%ci -1`, projectRoot)
|
|
72
|
+
});
|
|
56
73
|
} catch {
|
|
57
74
|
// Skip invalid branches
|
|
58
75
|
}
|
|
@@ -84,6 +101,10 @@ function hasUncommittedChanges(projectRoot) {
|
|
|
84
101
|
function rewindToCheckpoint(projectRoot, checkpoint, force = false) {
|
|
85
102
|
const currentCommit = getCurrentCommit(projectRoot);
|
|
86
103
|
|
|
104
|
+
if (!checkpoint.baseCommit) {
|
|
105
|
+
throw new Error(`Checkpoint ${checkpoint.lookupKey || checkpoint.sessionShort} is missing a linked base commit`);
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
if (!force && hasUncommittedChanges(projectRoot)) {
|
|
88
109
|
throw new Error('Working directory has uncommitted changes. Use --force to override or commit changes first.');
|
|
89
110
|
}
|
|
@@ -100,7 +121,7 @@ function rewindToCheckpoint(projectRoot, checkpoint, force = false) {
|
|
|
100
121
|
|
|
101
122
|
export default async function rewind(args) {
|
|
102
123
|
try {
|
|
103
|
-
const projectRoot =
|
|
124
|
+
const projectRoot = getProjectRoot();
|
|
104
125
|
const force = args.includes('--force') || args.includes('-f');
|
|
105
126
|
const interactive = args.includes('--interactive') || args.includes('-i');
|
|
106
127
|
|
|
@@ -112,7 +133,7 @@ export default async function rewind(args) {
|
|
|
112
133
|
|
|
113
134
|
if (checkpoints.length === 0) {
|
|
114
135
|
console.log('❌ No checkpoints found');
|
|
115
|
-
console.log(' Checkpoints are created
|
|
136
|
+
console.log(' Checkpoints are created when you run "shit commit".');
|
|
116
137
|
process.exit(1);
|
|
117
138
|
}
|
|
118
139
|
|
|
@@ -120,7 +141,8 @@ export default async function rewind(args) {
|
|
|
120
141
|
// Find specific checkpoint
|
|
121
142
|
targetCheckpoint = checkpoints.find(cp =>
|
|
122
143
|
cp.sessionShort.startsWith(checkpointArg) ||
|
|
123
|
-
cp.
|
|
144
|
+
cp.lookupKey.startsWith(checkpointArg) ||
|
|
145
|
+
(cp.baseCommit && cp.baseCommit.startsWith(checkpointArg))
|
|
124
146
|
);
|
|
125
147
|
|
|
126
148
|
if (!targetCheckpoint) {
|
|
@@ -132,22 +154,25 @@ export default async function rewind(args) {
|
|
|
132
154
|
console.log('📋 Available checkpoints:\n');
|
|
133
155
|
checkpoints.forEach((cp, i) => {
|
|
134
156
|
const date = new Date(cp.timestamp).toLocaleString();
|
|
135
|
-
|
|
157
|
+
const base = cp.baseCommit ? cp.baseCommit.slice(0, 7) : 'unknown';
|
|
158
|
+
const key = cp.lookupKey || cp.sessionShort;
|
|
159
|
+
console.log(`${i + 1}. ${key} (${base}) - ${date}`);
|
|
136
160
|
console.log(` ${cp.message}`);
|
|
137
161
|
console.log();
|
|
138
162
|
});
|
|
139
163
|
|
|
140
164
|
// For now, just use the most recent
|
|
141
165
|
targetCheckpoint = checkpoints[0];
|
|
142
|
-
console.log(`Using most recent checkpoint: ${targetCheckpoint.sessionShort}`);
|
|
166
|
+
console.log(`Using most recent checkpoint: ${targetCheckpoint.lookupKey || targetCheckpoint.sessionShort}`);
|
|
143
167
|
} else {
|
|
144
168
|
// Use most recent checkpoint
|
|
145
169
|
targetCheckpoint = checkpoints[0];
|
|
146
170
|
}
|
|
147
171
|
|
|
148
|
-
|
|
172
|
+
const selectedKey = targetCheckpoint.lookupKey || targetCheckpoint.sessionShort;
|
|
173
|
+
console.log(`🔄 Rewinding to checkpoint: ${selectedKey}`);
|
|
149
174
|
console.log(` Branch: ${targetCheckpoint.branch}`);
|
|
150
|
-
console.log(` Base commit: ${targetCheckpoint.baseCommit}`);
|
|
175
|
+
console.log(` Base commit: ${targetCheckpoint.baseCommit || 'unknown'}`);
|
|
151
176
|
console.log(` Created: ${new Date(targetCheckpoint.timestamp).toLocaleString()}`);
|
|
152
177
|
console.log();
|
|
153
178
|
|
|
@@ -164,7 +189,7 @@ export default async function rewind(args) {
|
|
|
164
189
|
console.log(` git reset --hard ${previousCommit}`);
|
|
165
190
|
console.log();
|
|
166
191
|
console.log('💡 To resume from this checkpoint:');
|
|
167
|
-
console.log(` shit resume ${
|
|
192
|
+
console.log(` shit resume ${selectedKey}`);
|
|
168
193
|
|
|
169
194
|
} catch (error) {
|
|
170
195
|
console.error('❌ Failed to rewind:', error.message);
|
package/lib/session.js
CHANGED
|
@@ -105,6 +105,12 @@ export function processEvent(state, event, hookType, projectRoot) {
|
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// Mark session as ended for end/stop hooks.
|
|
109
|
+
if (hookType === 'session-end' || hookType === 'SessionEnd' || hookType === 'stop' || hookType === 'session_end' || hookType === 'end') {
|
|
110
|
+
state.end_time = now;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
108
114
|
// Tool events
|
|
109
115
|
if (hookType === 'post-tool-use' && toolName) {
|
|
110
116
|
state.tool_counts[toolName] = (state.tool_counts[toolName] || 0) + 1;
|
package/lib/status.js
CHANGED
|
@@ -5,20 +5,10 @@
|
|
|
5
5
|
* Similar to 'entire status' - displays active session info
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync } from 'fs';
|
|
8
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { execSync } from 'child_process';
|
|
11
|
-
|
|
12
|
-
function findProjectRoot() {
|
|
13
|
-
let dir = process.cwd();
|
|
14
|
-
while (dir !== '/') {
|
|
15
|
-
if (existsSync(join(dir, '.git'))) {
|
|
16
|
-
return dir;
|
|
17
|
-
}
|
|
18
|
-
dir = join(dir, '..');
|
|
19
|
-
}
|
|
20
|
-
throw new Error('Not in a git repository');
|
|
21
|
-
}
|
|
11
|
+
import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
|
|
22
12
|
|
|
23
13
|
function getCurrentSession(projectRoot) {
|
|
24
14
|
const shitLogsDir = join(projectRoot, '.shit-logs');
|
|
@@ -27,9 +17,11 @@ function getCurrentSession(projectRoot) {
|
|
|
27
17
|
}
|
|
28
18
|
|
|
29
19
|
// Find the most recent session directory
|
|
30
|
-
const { readdirSync, statSync } = await import('fs');
|
|
31
20
|
const sessions = readdirSync(shitLogsDir)
|
|
32
|
-
.filter(name =>
|
|
21
|
+
.filter(name => {
|
|
22
|
+
const fullPath = join(shitLogsDir, name);
|
|
23
|
+
return SESSION_ID_REGEX.test(name) && statSync(fullPath).isDirectory();
|
|
24
|
+
})
|
|
33
25
|
.map(name => ({
|
|
34
26
|
name,
|
|
35
27
|
path: join(shitLogsDir, name),
|
|
@@ -90,11 +82,45 @@ function formatDuration(startTime) {
|
|
|
90
82
|
}
|
|
91
83
|
}
|
|
92
84
|
|
|
85
|
+
function getTouchedFileCount(state) {
|
|
86
|
+
const ops = state?.file_ops;
|
|
87
|
+
if (!ops || typeof ops !== 'object') {
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const touched = new Set([
|
|
92
|
+
...(Array.isArray(ops.write) ? ops.write : []),
|
|
93
|
+
...(Array.isArray(ops.edit) ? ops.edit : []),
|
|
94
|
+
...(Array.isArray(ops.read) ? ops.read : []),
|
|
95
|
+
].filter(Boolean));
|
|
96
|
+
|
|
97
|
+
return touched.size;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hasShitHooks(settings) {
|
|
101
|
+
if (!settings?.hooks || typeof settings.hooks !== 'object') {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return Object.values(settings.hooks).some(value => {
|
|
106
|
+
if (typeof value === 'string') {
|
|
107
|
+
return value.includes('shit log');
|
|
108
|
+
}
|
|
109
|
+
if (!Array.isArray(value)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return value.some(entry =>
|
|
113
|
+
Array.isArray(entry?.hooks) &&
|
|
114
|
+
entry.hooks.some(hook => typeof hook?.command === 'string' && hook.command.includes('shit log'))
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
93
119
|
export default async function status(args) {
|
|
94
120
|
try {
|
|
95
|
-
const projectRoot =
|
|
121
|
+
const projectRoot = getProjectRoot();
|
|
96
122
|
const gitInfo = getGitInfo(projectRoot);
|
|
97
|
-
const currentSession =
|
|
123
|
+
const currentSession = getCurrentSession(projectRoot);
|
|
98
124
|
|
|
99
125
|
console.log('📊 shit-cli Status\n');
|
|
100
126
|
|
|
@@ -121,7 +147,7 @@ export default async function status(args) {
|
|
|
121
147
|
console.log(` Started: ${new Date(state.start_time).toLocaleString()}`);
|
|
122
148
|
console.log(` Duration: ${formatDuration(state.start_time)}`);
|
|
123
149
|
console.log(` Events: ${state.event_count || 0}`);
|
|
124
|
-
console.log(` Files: ${
|
|
150
|
+
console.log(` Files: ${getTouchedFileCount(state)}`);
|
|
125
151
|
|
|
126
152
|
if (state.shadow_branch) {
|
|
127
153
|
console.log(` Shadow: ${state.shadow_branch}`);
|
|
@@ -149,8 +175,7 @@ export default async function status(args) {
|
|
|
149
175
|
if (existsSync(claudeSettings)) {
|
|
150
176
|
try {
|
|
151
177
|
const settings = JSON.parse(readFileSync(claudeSettings, 'utf-8'));
|
|
152
|
-
const hasHooks = settings
|
|
153
|
-
(settings.hooks.session_start || settings.hooks.session_end);
|
|
178
|
+
const hasHooks = hasShitHooks(settings);
|
|
154
179
|
|
|
155
180
|
console.log();
|
|
156
181
|
if (hasHooks) {
|
package/lib/summarize.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
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
|
+
|
|
13
|
+
// Default configuration
|
|
14
|
+
const DEFAULT_CONFIG = {
|
|
15
|
+
provider: 'openai', // or 'anthropic'
|
|
16
|
+
model: 'gpt-4o-mini',
|
|
17
|
+
max_tokens: 1000,
|
|
18
|
+
temperature: 0.7,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get API configuration from environment or config file
|
|
23
|
+
*/
|
|
24
|
+
function getApiConfig(projectRoot) {
|
|
25
|
+
// Check for environment variables first
|
|
26
|
+
const config = { ...DEFAULT_CONFIG };
|
|
27
|
+
|
|
28
|
+
// OpenAI
|
|
29
|
+
if (process.env.OPENAI_API_KEY) {
|
|
30
|
+
config.provider = 'openai';
|
|
31
|
+
config.api_key = process.env.OPENAI_API_KEY;
|
|
32
|
+
} else if (process.env.ANTHROPIC_API_KEY) {
|
|
33
|
+
config.provider = 'anthropic';
|
|
34
|
+
config.api_key = process.env.ANTHROPIC_API_KEY;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check for project config
|
|
38
|
+
const configFile = join(projectRoot, '.shit-logs', 'config.json');
|
|
39
|
+
if (existsSync(configFile)) {
|
|
40
|
+
try {
|
|
41
|
+
const fileConfig = JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
42
|
+
Object.assign(config, fileConfig);
|
|
43
|
+
} catch {
|
|
44
|
+
// Use defaults
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Extract relevant context from session for summarization
|
|
53
|
+
*/
|
|
54
|
+
function extractContext(sessionDir) {
|
|
55
|
+
const context = {
|
|
56
|
+
prompts: [],
|
|
57
|
+
changes: [],
|
|
58
|
+
tools: {},
|
|
59
|
+
errors: [],
|
|
60
|
+
summary: null,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Read summary.json
|
|
64
|
+
const summaryFile = join(sessionDir, 'summary.json');
|
|
65
|
+
if (existsSync(summaryFile)) {
|
|
66
|
+
try {
|
|
67
|
+
const summary = JSON.parse(readFileSync(summaryFile, 'utf-8'));
|
|
68
|
+
context.summary = summary;
|
|
69
|
+
context.prompts = summary.prompts || [];
|
|
70
|
+
context.tools = summary.activity?.tools || {};
|
|
71
|
+
context.errors = summary.activity?.errors || [];
|
|
72
|
+
context.changes = summary.changes?.files || [];
|
|
73
|
+
} catch {
|
|
74
|
+
// Best effort
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Read prompts.txt
|
|
79
|
+
const promptsFile = join(sessionDir, 'prompts.txt');
|
|
80
|
+
if (existsSync(promptsFile)) {
|
|
81
|
+
try {
|
|
82
|
+
context.prompts_text = readFileSync(promptsFile, 'utf-8').slice(0, 3000);
|
|
83
|
+
} catch {
|
|
84
|
+
// Best effort
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Read context.md
|
|
89
|
+
const contextFile = join(sessionDir, 'context.md');
|
|
90
|
+
if (existsSync(contextFile)) {
|
|
91
|
+
try {
|
|
92
|
+
context.context_md = readFileSync(contextFile, 'utf-8').slice(0, 2000);
|
|
93
|
+
} catch {
|
|
94
|
+
// Best effort
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return context;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build prompt for LLM summarization
|
|
103
|
+
*/
|
|
104
|
+
function buildSummarizePrompt(context) {
|
|
105
|
+
const parts = [];
|
|
106
|
+
|
|
107
|
+
// System prompt
|
|
108
|
+
parts.push(`You are a helpful assistant that summarizes AI coding sessions. Generate a concise summary that explains:`);
|
|
109
|
+
parts.push(`1. What the user wanted to accomplish`);
|
|
110
|
+
parts.push(`2. What changes were made`);
|
|
111
|
+
parts.push(`3. Any issues or errors encountered`);
|
|
112
|
+
parts.push(`4. Overall outcome`);
|
|
113
|
+
|
|
114
|
+
parts.push(`\n---\n`);
|
|
115
|
+
|
|
116
|
+
// User prompts
|
|
117
|
+
if (context.prompts_text) {
|
|
118
|
+
parts.push(`## User Prompts\n${context.prompts_text}\n`);
|
|
119
|
+
} else if (context.prompts && context.prompts.length > 0) {
|
|
120
|
+
parts.push(`## User Prompts\n`);
|
|
121
|
+
context.prompts.slice(0, 5).forEach(p => {
|
|
122
|
+
const text = typeof p === 'string' ? p : p.text || '';
|
|
123
|
+
parts.push(`- ${text.slice(0, 200)}`);
|
|
124
|
+
});
|
|
125
|
+
parts.push('');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Changes summary
|
|
129
|
+
if (context.changes && context.changes.length > 0) {
|
|
130
|
+
parts.push(`## Files Changed\n`);
|
|
131
|
+
context.changes.slice(0, 10).forEach(f => {
|
|
132
|
+
const ops = f.operations?.join(', ') || 'modified';
|
|
133
|
+
parts.push(`- ${f.path}: ${ops}`);
|
|
134
|
+
});
|
|
135
|
+
parts.push('');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Tool usage
|
|
139
|
+
if (context.tools && Object.keys(context.tools).length > 0) {
|
|
140
|
+
parts.push(`## Tools Used\n`);
|
|
141
|
+
Object.entries(context.tools).forEach(([tool, count]) => {
|
|
142
|
+
parts.push(`- ${tool}: ${count} times`);
|
|
143
|
+
});
|
|
144
|
+
parts.push('');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Errors
|
|
148
|
+
if (context.errors && context.errors.length > 0) {
|
|
149
|
+
parts.push(`## Errors\n`);
|
|
150
|
+
context.errors.slice(0, 5).forEach(e => {
|
|
151
|
+
parts.push(`- ${e.tool}: ${(e.message || '').slice(0, 100)}`);
|
|
152
|
+
});
|
|
153
|
+
parts.push('');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return parts.join('\n');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Call OpenAI API
|
|
161
|
+
*/
|
|
162
|
+
async function callOpenAI(apiKey, model, prompt, maxTokens, temperature) {
|
|
163
|
+
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: {
|
|
166
|
+
'Content-Type': 'application/json',
|
|
167
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
168
|
+
},
|
|
169
|
+
body: JSON.stringify({
|
|
170
|
+
model,
|
|
171
|
+
messages: [
|
|
172
|
+
{ role: 'system', content: 'You are a helpful assistant that summarizes AI coding sessions.' },
|
|
173
|
+
{ role: 'user', content: prompt }
|
|
174
|
+
],
|
|
175
|
+
max_tokens: maxTokens,
|
|
176
|
+
temperature,
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
const error = await response.text();
|
|
182
|
+
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const data = await response.json();
|
|
186
|
+
return data.choices[0].message.content;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Call Anthropic API
|
|
191
|
+
*/
|
|
192
|
+
async function callAnthropic(apiKey, model, prompt, maxTokens, temperature) {
|
|
193
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: {
|
|
196
|
+
'Content-Type': 'application/json',
|
|
197
|
+
'x-api-key': apiKey,
|
|
198
|
+
'anthropic-version': '2023-06-01',
|
|
199
|
+
},
|
|
200
|
+
body: JSON.stringify({
|
|
201
|
+
model,
|
|
202
|
+
max_tokens: maxTokens,
|
|
203
|
+
temperature,
|
|
204
|
+
messages: [
|
|
205
|
+
{ role: 'user', content: prompt }
|
|
206
|
+
],
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
const error = await response.text();
|
|
212
|
+
throw new Error(`Anthropic API error: ${response.status} - ${error}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const data = await response.json();
|
|
216
|
+
return data.content[0].text;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Generate AI summary for a session
|
|
221
|
+
*/
|
|
222
|
+
export async function summarizeSession(projectRoot, sessionId, sessionDir) {
|
|
223
|
+
const config = getApiConfig(projectRoot);
|
|
224
|
+
|
|
225
|
+
if (!config.api_key) {
|
|
226
|
+
return {
|
|
227
|
+
success: false,
|
|
228
|
+
reason: 'No API key configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.'
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Extract context from session
|
|
233
|
+
const context = extractContext(sessionDir);
|
|
234
|
+
|
|
235
|
+
// Build prompt
|
|
236
|
+
const prompt = buildSummarizePrompt(context);
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
let summary;
|
|
240
|
+
|
|
241
|
+
if (config.provider === 'anthropic') {
|
|
242
|
+
summary = await callAnthropic(
|
|
243
|
+
config.api_key,
|
|
244
|
+
config.model || 'claude-3-haiku-20240307',
|
|
245
|
+
prompt,
|
|
246
|
+
config.max_tokens,
|
|
247
|
+
config.temperature
|
|
248
|
+
);
|
|
249
|
+
} else {
|
|
250
|
+
summary = await callOpenAI(
|
|
251
|
+
config.api_key,
|
|
252
|
+
config.model || 'gpt-4o-mini',
|
|
253
|
+
prompt,
|
|
254
|
+
config.max_tokens,
|
|
255
|
+
config.temperature
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Save summary
|
|
260
|
+
const aiSummaryFile = join(sessionDir, 'ai-summary.md');
|
|
261
|
+
writeFileSync(aiSummaryFile, summary);
|
|
262
|
+
|
|
263
|
+
// Update state
|
|
264
|
+
const stateFile = join(sessionDir, 'state.json');
|
|
265
|
+
if (existsSync(stateFile)) {
|
|
266
|
+
try {
|
|
267
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
268
|
+
state.ai_summary = {
|
|
269
|
+
provider: config.provider,
|
|
270
|
+
model: config.model,
|
|
271
|
+
generated_at: new Date().toISOString(),
|
|
272
|
+
};
|
|
273
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
274
|
+
} catch {
|
|
275
|
+
// Best effort
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
success: true,
|
|
281
|
+
summary,
|
|
282
|
+
provider: config.provider,
|
|
283
|
+
model: config.model,
|
|
284
|
+
};
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return {
|
|
287
|
+
success: false,
|
|
288
|
+
reason: error.message
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* CLI command for manual summarization
|
|
295
|
+
*/
|
|
296
|
+
export default async function summarize(args) {
|
|
297
|
+
const projectRoot = getProjectRoot();
|
|
298
|
+
const sessionId = args[0];
|
|
299
|
+
|
|
300
|
+
if (!sessionId) {
|
|
301
|
+
console.log('Usage: shit summarize <session-id>');
|
|
302
|
+
console.log('\nEnvironment variables:');
|
|
303
|
+
console.log(' OPENAI_API_KEY # Use OpenAI for summarization');
|
|
304
|
+
console.log(' ANTHROPIC_API_KEY # Use Anthropic for summarization');
|
|
305
|
+
console.log('\nConfiguration (.shit-logs/config.json):');
|
|
306
|
+
console.log(` {"provider": "openai", "model": "gpt-4o-mini"}`);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const sessionDir = join(projectRoot, '.shit-logs', sessionId);
|
|
311
|
+
|
|
312
|
+
if (!existsSync(sessionDir)) {
|
|
313
|
+
console.error(`Session not found: ${sessionId}`);
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
console.log(`🤖 Generating AI summary for session: ${sessionId}\n`);
|
|
318
|
+
|
|
319
|
+
const result = await summarizeSession(projectRoot, sessionId, sessionDir);
|
|
320
|
+
|
|
321
|
+
if (result.success) {
|
|
322
|
+
console.log('✅ AI Summary generated!\n');
|
|
323
|
+
console.log(result.summary);
|
|
324
|
+
console.log(`\n---`);
|
|
325
|
+
console.log(`Provider: ${result.provider}`);
|
|
326
|
+
console.log(`Model: ${result.model}`);
|
|
327
|
+
} else {
|
|
328
|
+
console.error('❌ Failed to generate summary:', result.reason);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claudemini/shit-cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Session-based Hook Intelligence Tracker -
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Session-based Hook Intelligence Tracker - Zero-dependency memory system for human-AI coding sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"shit": "./bin/shit.js"
|
|
8
8
|
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18.0.0"
|
|
17
|
+
},
|
|
9
18
|
"scripts": {
|
|
10
19
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
11
20
|
},
|
|
12
21
|
"keywords": [
|
|
13
22
|
"claude-code",
|
|
23
|
+
"gemini-cli",
|
|
24
|
+
"cursor",
|
|
25
|
+
"ai-coding",
|
|
14
26
|
"hooks",
|
|
15
|
-
"
|
|
16
|
-
"
|
|
27
|
+
"session-tracking",
|
|
28
|
+
"code-review",
|
|
29
|
+
"checkpoint"
|
|
17
30
|
],
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/anthropics/shit-cli.git"
|
|
34
|
+
},
|
|
18
35
|
"author": "",
|
|
19
36
|
"license": "MIT",
|
|
20
37
|
"dependencies": {},
|