slashvibe-mcp 0.3.19 → 0.3.21
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 +14 -1
- package/index.js +19 -7
- package/package.json +2 -1
- package/smart-inbox.js +276 -0
- package/store/api.js +100 -4
- package/tools/broadcast.js +45 -6
- package/tools/health.js +87 -0
- package/tools/leaderboard.js +117 -0
- package/tools/lib/git-apply.js +206 -0
- package/tools/lib/git-bundle.js +407 -0
- package/tools/session.js +50 -3
- package/tools/streak.js +147 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Bundle Library
|
|
3
|
+
*
|
|
4
|
+
* Creates and validates git bundles for session sharing.
|
|
5
|
+
* Used by broadcast.js to capture code changes during a broadcast.
|
|
6
|
+
*
|
|
7
|
+
* Key functions:
|
|
8
|
+
* - captureSessionStart() - Returns current HEAD commit
|
|
9
|
+
* - createBundle(startCommit) - Creates bundle from startCommit to HEAD
|
|
10
|
+
* - validateBundle(buffer) - Validates bundle integrity
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { execSync, spawn } = require('child_process');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
|
|
18
|
+
// Max bundle size (10MB)
|
|
19
|
+
const MAX_BUNDLE_SIZE = 10 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
// Files to exclude from bundles (security)
|
|
22
|
+
const EXCLUDED_PATTERNS = [
|
|
23
|
+
'.env',
|
|
24
|
+
'.env.*',
|
|
25
|
+
'*.pem',
|
|
26
|
+
'*.key',
|
|
27
|
+
'*.p12',
|
|
28
|
+
'*.pfx',
|
|
29
|
+
'.npmrc',
|
|
30
|
+
'.netrc',
|
|
31
|
+
'credentials.json',
|
|
32
|
+
'secrets.json',
|
|
33
|
+
'*_rsa',
|
|
34
|
+
'*_dsa',
|
|
35
|
+
'*_ed25519',
|
|
36
|
+
'*_ecdsa',
|
|
37
|
+
'id_rsa*',
|
|
38
|
+
'id_dsa*',
|
|
39
|
+
'*.keystore',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if we're in a git repository
|
|
44
|
+
*/
|
|
45
|
+
function isGitRepo() {
|
|
46
|
+
try {
|
|
47
|
+
execSync('git rev-parse --is-inside-work-tree', {
|
|
48
|
+
stdio: 'pipe',
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
});
|
|
51
|
+
return true;
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get current git branch
|
|
59
|
+
*/
|
|
60
|
+
function getCurrentBranch() {
|
|
61
|
+
try {
|
|
62
|
+
return execSync('git rev-parse --abbrev-ref HEAD', {
|
|
63
|
+
stdio: 'pipe',
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
}).trim();
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get current HEAD commit hash
|
|
73
|
+
*/
|
|
74
|
+
function getHeadCommit() {
|
|
75
|
+
try {
|
|
76
|
+
return execSync('git rev-parse HEAD', {
|
|
77
|
+
stdio: 'pipe',
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
}).trim();
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get commit count between two commits
|
|
87
|
+
*/
|
|
88
|
+
function getCommitCount(startCommit, endCommit = 'HEAD') {
|
|
89
|
+
try {
|
|
90
|
+
const output = execSync(`git rev-list --count ${startCommit}..${endCommit}`, {
|
|
91
|
+
stdio: 'pipe',
|
|
92
|
+
encoding: 'utf8',
|
|
93
|
+
});
|
|
94
|
+
return parseInt(output.trim(), 10);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get list of files changed between commits
|
|
102
|
+
*/
|
|
103
|
+
function getChangedFiles(startCommit, endCommit = 'HEAD') {
|
|
104
|
+
try {
|
|
105
|
+
const output = execSync(`git diff --name-only ${startCommit}..${endCommit}`, {
|
|
106
|
+
stdio: 'pipe',
|
|
107
|
+
encoding: 'utf8',
|
|
108
|
+
});
|
|
109
|
+
return output.trim().split('\n').filter(Boolean);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if any excluded files would be in the bundle
|
|
117
|
+
*/
|
|
118
|
+
function hasExcludedFiles(changedFiles) {
|
|
119
|
+
const excluded = [];
|
|
120
|
+
|
|
121
|
+
for (const file of changedFiles) {
|
|
122
|
+
const basename = path.basename(file);
|
|
123
|
+
|
|
124
|
+
for (const pattern of EXCLUDED_PATTERNS) {
|
|
125
|
+
// Simple glob matching
|
|
126
|
+
if (pattern.startsWith('*')) {
|
|
127
|
+
const suffix = pattern.slice(1);
|
|
128
|
+
if (basename.endsWith(suffix)) {
|
|
129
|
+
excluded.push(file);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
} else if (pattern.endsWith('*')) {
|
|
133
|
+
const prefix = pattern.slice(0, -1);
|
|
134
|
+
if (basename.startsWith(prefix)) {
|
|
135
|
+
excluded.push(file);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
} else if (pattern.includes('*')) {
|
|
139
|
+
// Pattern like ".env.*"
|
|
140
|
+
const [prefix, suffix] = pattern.split('*');
|
|
141
|
+
if (basename.startsWith(prefix) && (suffix === '' || basename.endsWith(suffix))) {
|
|
142
|
+
excluded.push(file);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
} else if (basename === pattern || file === pattern) {
|
|
146
|
+
excluded.push(file);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return excluded;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Capture git state at session start
|
|
157
|
+
* @returns {object} - { success, commit, branch, error? }
|
|
158
|
+
*/
|
|
159
|
+
function captureSessionStart() {
|
|
160
|
+
if (!isGitRepo()) {
|
|
161
|
+
return { success: false, error: 'Not a git repository' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const commit = getHeadCommit();
|
|
165
|
+
const branch = getCurrentBranch();
|
|
166
|
+
|
|
167
|
+
if (!commit) {
|
|
168
|
+
return { success: false, error: 'Could not get HEAD commit' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
success: true,
|
|
173
|
+
commit,
|
|
174
|
+
branch,
|
|
175
|
+
capturedAt: new Date().toISOString(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create a git bundle from startCommit to HEAD
|
|
181
|
+
* @param {string} startCommit - Starting commit hash
|
|
182
|
+
* @returns {object} - { success, buffer, metadata, error? }
|
|
183
|
+
*/
|
|
184
|
+
function createBundle(startCommit) {
|
|
185
|
+
if (!isGitRepo()) {
|
|
186
|
+
return { success: false, error: 'Not a git repository' };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!startCommit) {
|
|
190
|
+
return { success: false, error: 'Start commit required' };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const endCommit = getHeadCommit();
|
|
194
|
+
const branch = getCurrentBranch();
|
|
195
|
+
|
|
196
|
+
if (!endCommit) {
|
|
197
|
+
return { success: false, error: 'Could not get HEAD commit' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Check if there are any new commits
|
|
201
|
+
const commitCount = getCommitCount(startCommit, endCommit);
|
|
202
|
+
if (commitCount === 0) {
|
|
203
|
+
return {
|
|
204
|
+
success: false,
|
|
205
|
+
error: 'No new commits since session start',
|
|
206
|
+
metadata: {
|
|
207
|
+
startCommit,
|
|
208
|
+
endCommit,
|
|
209
|
+
branch,
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Check for excluded files
|
|
215
|
+
const changedFiles = getChangedFiles(startCommit, endCommit);
|
|
216
|
+
const excludedFiles = hasExcludedFiles(changedFiles);
|
|
217
|
+
|
|
218
|
+
if (excludedFiles.length > 0) {
|
|
219
|
+
console.log(`[git-bundle] Warning: Excluding sensitive files: ${excludedFiles.join(', ')}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Create temp file for bundle
|
|
223
|
+
const tmpDir = os.tmpdir();
|
|
224
|
+
const bundlePath = path.join(tmpDir, `vibe-bundle-${Date.now()}.bundle`);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
// Create the bundle
|
|
228
|
+
// Format: git bundle create <file> <range>
|
|
229
|
+
const bundleRange = `${startCommit}..HEAD`;
|
|
230
|
+
|
|
231
|
+
execSync(`git bundle create "${bundlePath}" ${bundleRange}`, {
|
|
232
|
+
stdio: 'pipe',
|
|
233
|
+
encoding: 'utf8',
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Read the bundle
|
|
237
|
+
const buffer = fs.readFileSync(bundlePath);
|
|
238
|
+
|
|
239
|
+
// Check size
|
|
240
|
+
if (buffer.length > MAX_BUNDLE_SIZE) {
|
|
241
|
+
fs.unlinkSync(bundlePath);
|
|
242
|
+
return {
|
|
243
|
+
success: false,
|
|
244
|
+
error: `Bundle too large (${Math.round(buffer.length / 1024 / 1024)}MB > ${MAX_BUNDLE_SIZE / 1024 / 1024}MB limit)`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Clean up temp file
|
|
249
|
+
fs.unlinkSync(bundlePath);
|
|
250
|
+
|
|
251
|
+
const metadata = {
|
|
252
|
+
initialCommit: startCommit,
|
|
253
|
+
finalCommit: endCommit,
|
|
254
|
+
branch,
|
|
255
|
+
commitCount,
|
|
256
|
+
changedFiles: changedFiles.filter((f) => !excludedFiles.includes(f)),
|
|
257
|
+
excludedFiles,
|
|
258
|
+
size: buffer.length,
|
|
259
|
+
createdAt: new Date().toISOString(),
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
console.log(`[git-bundle] Created bundle: ${commitCount} commits, ${changedFiles.length} files, ${buffer.length} bytes`);
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
success: true,
|
|
266
|
+
buffer,
|
|
267
|
+
metadata,
|
|
268
|
+
};
|
|
269
|
+
} catch (error) {
|
|
270
|
+
// Clean up on error
|
|
271
|
+
try {
|
|
272
|
+
if (fs.existsSync(bundlePath)) {
|
|
273
|
+
fs.unlinkSync(bundlePath);
|
|
274
|
+
}
|
|
275
|
+
} catch (e) {
|
|
276
|
+
// Ignore cleanup errors
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
console.error('[git-bundle] Failed to create bundle:', error.message);
|
|
280
|
+
return { success: false, error: error.message };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Validate a git bundle
|
|
286
|
+
* @param {Buffer} buffer - The bundle data
|
|
287
|
+
* @returns {object} - { success, info, error? }
|
|
288
|
+
*/
|
|
289
|
+
function validateBundle(buffer) {
|
|
290
|
+
if (!buffer || buffer.length === 0) {
|
|
291
|
+
return { success: false, error: 'Empty buffer' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Check magic bytes (git bundles start with "# v2 git bundle" or "# v3 git bundle")
|
|
295
|
+
const header = buffer.slice(0, 20).toString('utf8');
|
|
296
|
+
if (!header.startsWith('# v2 git bundle') && !header.startsWith('# v3 git bundle')) {
|
|
297
|
+
return { success: false, error: 'Invalid bundle format' };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Write to temp and verify
|
|
301
|
+
const tmpDir = os.tmpdir();
|
|
302
|
+
const bundlePath = path.join(tmpDir, `vibe-verify-${Date.now()}.bundle`);
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
fs.writeFileSync(bundlePath, buffer);
|
|
306
|
+
|
|
307
|
+
// Verify the bundle
|
|
308
|
+
const output = execSync(`git bundle verify "${bundlePath}"`, {
|
|
309
|
+
stdio: 'pipe',
|
|
310
|
+
encoding: 'utf8',
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
fs.unlinkSync(bundlePath);
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
success: true,
|
|
317
|
+
info: output.trim(),
|
|
318
|
+
size: buffer.length,
|
|
319
|
+
};
|
|
320
|
+
} catch (error) {
|
|
321
|
+
try {
|
|
322
|
+
if (fs.existsSync(bundlePath)) {
|
|
323
|
+
fs.unlinkSync(bundlePath);
|
|
324
|
+
}
|
|
325
|
+
} catch (e) {
|
|
326
|
+
// Ignore cleanup errors
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { success: false, error: error.message };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Apply a bundle to create a new branch (for forking)
|
|
335
|
+
* @param {Buffer} buffer - The bundle data
|
|
336
|
+
* @param {string} branchName - Name for the new branch
|
|
337
|
+
* @returns {object} - { success, branch, error? }
|
|
338
|
+
*/
|
|
339
|
+
function applyBundle(buffer, branchName = 'forked-session') {
|
|
340
|
+
if (!isGitRepo()) {
|
|
341
|
+
return { success: false, error: 'Not a git repository' };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const validation = validateBundle(buffer);
|
|
345
|
+
if (!validation.success) {
|
|
346
|
+
return { success: false, error: `Invalid bundle: ${validation.error}` };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const tmpDir = os.tmpdir();
|
|
350
|
+
const bundlePath = path.join(tmpDir, `vibe-apply-${Date.now()}.bundle`);
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
fs.writeFileSync(bundlePath, buffer);
|
|
354
|
+
|
|
355
|
+
// Get the refs in the bundle
|
|
356
|
+
const listOutput = execSync(`git bundle list-heads "${bundlePath}"`, {
|
|
357
|
+
stdio: 'pipe',
|
|
358
|
+
encoding: 'utf8',
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const refs = listOutput.trim().split('\n');
|
|
362
|
+
if (refs.length === 0) {
|
|
363
|
+
throw new Error('No refs in bundle');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Get the first commit hash from the bundle
|
|
367
|
+
const firstRef = refs[0].split(/\s+/)[0];
|
|
368
|
+
|
|
369
|
+
// Unbundle (fetch from bundle)
|
|
370
|
+
execSync(`git fetch "${bundlePath}" ${firstRef}:${branchName}`, {
|
|
371
|
+
stdio: 'pipe',
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
fs.unlinkSync(bundlePath);
|
|
375
|
+
|
|
376
|
+
console.log(`[git-bundle] Applied bundle to branch: ${branchName}`);
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
success: true,
|
|
380
|
+
branch: branchName,
|
|
381
|
+
refs: refs.map((r) => r.split(/\s+/)),
|
|
382
|
+
};
|
|
383
|
+
} catch (error) {
|
|
384
|
+
try {
|
|
385
|
+
if (fs.existsSync(bundlePath)) {
|
|
386
|
+
fs.unlinkSync(bundlePath);
|
|
387
|
+
}
|
|
388
|
+
} catch (e) {
|
|
389
|
+
// Ignore cleanup errors
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
console.error('[git-bundle] Failed to apply bundle:', error.message);
|
|
393
|
+
return { success: false, error: error.message };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
module.exports = {
|
|
398
|
+
isGitRepo,
|
|
399
|
+
getCurrentBranch,
|
|
400
|
+
getHeadCommit,
|
|
401
|
+
captureSessionStart,
|
|
402
|
+
createBundle,
|
|
403
|
+
validateBundle,
|
|
404
|
+
applyBundle,
|
|
405
|
+
MAX_BUNDLE_SIZE,
|
|
406
|
+
EXCLUDED_PATTERNS,
|
|
407
|
+
};
|
package/tools/session.js
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
* - session save --room X → Save specific broadcast
|
|
9
9
|
* - session list → List my sessions
|
|
10
10
|
* - session browse → Browse all public sessions
|
|
11
|
-
* - session fork <id> → Fork a session
|
|
11
|
+
* - session fork <id> → Fork a session (story only)
|
|
12
|
+
* - session fork <id> --full → Fork with git bundle (get actual code)
|
|
12
13
|
* - session view <id> → View session details
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
const config = require('../config');
|
|
17
|
+
const gitApply = require('./lib/git-apply');
|
|
16
18
|
|
|
17
19
|
const definition = {
|
|
18
20
|
name: 'vibe_session',
|
|
@@ -45,6 +47,14 @@ const definition = {
|
|
|
45
47
|
limit: {
|
|
46
48
|
type: 'number',
|
|
47
49
|
description: 'Number of sessions to show (default: 10)'
|
|
50
|
+
},
|
|
51
|
+
full: {
|
|
52
|
+
type: 'boolean',
|
|
53
|
+
description: 'For fork: download git bundle and apply locally (default: false)'
|
|
54
|
+
},
|
|
55
|
+
branch: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
description: 'For fork --full: branch name for applied code (default: forked-session)'
|
|
48
58
|
}
|
|
49
59
|
}
|
|
50
60
|
}
|
|
@@ -373,6 +383,9 @@ async function handler(args) {
|
|
|
373
383
|
}
|
|
374
384
|
|
|
375
385
|
const sessionId = args.id;
|
|
386
|
+
const fullFork = args.full === true;
|
|
387
|
+
const branchName = args.branch || 'forked-session';
|
|
388
|
+
const forkLevel = fullFork ? 'full' : 'story_only';
|
|
376
389
|
|
|
377
390
|
try {
|
|
378
391
|
const response = await fetch(`${apiUrl}/api/sessions/fork`, {
|
|
@@ -382,7 +395,7 @@ async function handler(args) {
|
|
|
382
395
|
parentSessionId: sessionId,
|
|
383
396
|
forkerHandle: myHandle,
|
|
384
397
|
forkTo: 'session',
|
|
385
|
-
forkLevel
|
|
398
|
+
forkLevel
|
|
386
399
|
})
|
|
387
400
|
});
|
|
388
401
|
|
|
@@ -404,8 +417,42 @@ async function handler(args) {
|
|
|
404
417
|
display += `**From:** ${data.parentSession?.title || sessionId}\n`;
|
|
405
418
|
display += `**By:** @${data.parentSession?.author || 'unknown'}\n\n`;
|
|
406
419
|
display += `**Fork ID:** ${data.forkId}\n\n`;
|
|
420
|
+
|
|
421
|
+
// Handle full fork with git bundle
|
|
422
|
+
if (fullFork && data.git?.bundleDownloadUrl) {
|
|
423
|
+
display += `📦 **Downloading git bundle...**\n`;
|
|
424
|
+
|
|
425
|
+
const applyResult = await gitApply.applyBundleFromUrl(
|
|
426
|
+
data.git.bundleDownloadUrl,
|
|
427
|
+
branchName
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
if (applyResult.success) {
|
|
431
|
+
display += `✅ **Code applied to branch:** \`${applyResult.branch}\`\n\n`;
|
|
432
|
+
display += `**Git info:**\n`;
|
|
433
|
+
display += `- Branch: ${data.git.branch || 'unknown'}\n`;
|
|
434
|
+
display += `- Commits: ${data.git.initialCommit?.slice(0, 7)}..${data.git.finalCommit?.slice(0, 7)}\n`;
|
|
435
|
+
display += `- Bundle size: ${Math.round(applyResult.size / 1024)}KB\n\n`;
|
|
436
|
+
display += `**Next steps:**\n`;
|
|
437
|
+
display += `\`\`\`\ngit checkout ${applyResult.branch}\n\`\`\`\n`;
|
|
438
|
+
} else {
|
|
439
|
+
display += `⚠️ **Bundle apply failed:** ${applyResult.error}\n\n`;
|
|
440
|
+
display += `_The session was forked, but the code bundle could not be applied._\n`;
|
|
441
|
+
display += `_You can still view the story at the URL below._\n\n`;
|
|
442
|
+
}
|
|
443
|
+
} else if (fullFork && data.git?.hasBundleAvailable && !data.git?.bundleDownloadUrl) {
|
|
444
|
+
display += `⚠️ **Git bundle not available** (R2 storage may not be configured)\n\n`;
|
|
445
|
+
} else if (fullFork && !data.git?.hasBundleAvailable) {
|
|
446
|
+
display += `ℹ️ **No git bundle** for this session (no commits during broadcast)\n\n`;
|
|
447
|
+
}
|
|
448
|
+
|
|
407
449
|
display += `**View the story:**\n${data.storyUrl}\n\n`;
|
|
408
|
-
|
|
450
|
+
|
|
451
|
+
if (!fullFork && data.git?.hasBundleAvailable) {
|
|
452
|
+
display += `_💡 Want the actual code? Use \`vibe session fork ${sessionId} --full\`_`;
|
|
453
|
+
} else if (!fullFork) {
|
|
454
|
+
display += `_Learn from this session and build your own version!_`;
|
|
455
|
+
}
|
|
409
456
|
|
|
410
457
|
return { display };
|
|
411
458
|
|
package/tools/streak.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vibe_streak — View and track your building streak
|
|
3
|
+
*
|
|
4
|
+
* Streaks drive retention through daily habit formation.
|
|
5
|
+
*
|
|
6
|
+
* Badge Tiers:
|
|
7
|
+
* - 7 days: Verified Builder
|
|
8
|
+
* - 14 days: Consistent Builder
|
|
9
|
+
* - 30 days: Dedicated Builder
|
|
10
|
+
* - 60 days: Relentless Builder
|
|
11
|
+
* - 100 days: Legendary Builder
|
|
12
|
+
*
|
|
13
|
+
* Streak Freezes:
|
|
14
|
+
* - Earn 1 freeze every 7 days (max 3)
|
|
15
|
+
* - Auto-used if you miss a day
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const config = require('../config');
|
|
19
|
+
|
|
20
|
+
const API_URL = process.env.VIBE_API_URL || 'https://www.slashvibe.dev';
|
|
21
|
+
|
|
22
|
+
const definition = {
|
|
23
|
+
name: 'vibe_streak',
|
|
24
|
+
description: 'View your building streak, badges, and freezes',
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
user: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'Username to check (default: yourself)'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function requireInit() {
|
|
37
|
+
if (!config.isInitialized()) {
|
|
38
|
+
throw new Error('Not initialized. Run `vibe init` first.');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function handler(args) {
|
|
43
|
+
requireInit();
|
|
44
|
+
|
|
45
|
+
const myHandle = config.getHandle();
|
|
46
|
+
const targetUser = args.user ? args.user.toLowerCase().replace('@', '') : myHandle;
|
|
47
|
+
const isOwnStreak = targetUser === myHandle.toLowerCase();
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const endpoint = `${API_URL}/api/growth/streak?user=${targetUser}`;
|
|
51
|
+
const response = await fetch(endpoint);
|
|
52
|
+
const data = await response.json();
|
|
53
|
+
|
|
54
|
+
if (!data.success) {
|
|
55
|
+
return { display: `\u274c Failed to load streak: ${data.error}` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const streak = data.streak;
|
|
59
|
+
let display = '';
|
|
60
|
+
|
|
61
|
+
// Header with current streak
|
|
62
|
+
if (isOwnStreak) {
|
|
63
|
+
display += `## Your Building Streak\n\n`;
|
|
64
|
+
} else {
|
|
65
|
+
display += `## @${targetUser}'s Building Streak\n\n`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Big streak number with fire emoji based on length
|
|
69
|
+
const fireEmoji = streak.current >= 30 ? '\ud83d\udd25\ud83d\udd25\ud83d\udd25' :
|
|
70
|
+
streak.current >= 14 ? '\ud83d\udd25\ud83d\udd25' :
|
|
71
|
+
streak.current >= 7 ? '\ud83d\udd25' : '\ud83d\udcaa';
|
|
72
|
+
|
|
73
|
+
display += `### ${fireEmoji} ${streak.current} Day Streak\n\n`;
|
|
74
|
+
|
|
75
|
+
// Status indicators
|
|
76
|
+
if (streak.atRisk) {
|
|
77
|
+
display += `\u26a0\ufe0f **AT RISK** - Ship something today to keep your streak!\n\n`;
|
|
78
|
+
} else if (streak.isActiveToday) {
|
|
79
|
+
display += `\u2705 **Active today** - streak is safe!\n\n`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Stats
|
|
83
|
+
display += `| Stat | Value |\n`;
|
|
84
|
+
display += `|------|-------|\n`;
|
|
85
|
+
display += `| Current Streak | ${streak.current} days |\n`;
|
|
86
|
+
display += `| Longest Streak | ${streak.longest} days |\n`;
|
|
87
|
+
display += `| Freezes Available | ${data.freezesAvailable}\u2744\ufe0f |\n`;
|
|
88
|
+
display += `| Freezes Used | ${streak.freezesUsed || 0} |\n\n`;
|
|
89
|
+
|
|
90
|
+
// Current tier
|
|
91
|
+
if (streak.tier?.current) {
|
|
92
|
+
display += `### Current Tier: ${streak.tier.current.emoji} ${streak.tier.current.label}\n\n`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Next badge progress
|
|
96
|
+
if (data.nextBadge) {
|
|
97
|
+
const progress = Math.round((streak.current / data.nextBadge.daysAway + streak.current) * 100);
|
|
98
|
+
display += `### Next Badge: ${data.nextBadge.emoji} ${data.nextBadge.label}\n`;
|
|
99
|
+
display += `**${data.nextBadge.daysAway} days** to unlock\n\n`;
|
|
100
|
+
} else if (streak.current >= 100) {
|
|
101
|
+
display += `### \ud83c\udf1f All badges unlocked! You're a legend.\n\n`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Earned badges
|
|
105
|
+
if (streak.badges && streak.badges.length > 0) {
|
|
106
|
+
display += `### Earned Badges\n`;
|
|
107
|
+
const badgeInfo = {
|
|
108
|
+
verified_builder: { emoji: '\u2705', label: 'Verified Builder (7d)' },
|
|
109
|
+
consistent_builder: { emoji: '\ud83d\udcaa', label: 'Consistent Builder (14d)' },
|
|
110
|
+
dedicated_builder: { emoji: '\ud83d\udd25', label: 'Dedicated Builder (30d)' },
|
|
111
|
+
relentless_builder: { emoji: '\u26a1', label: 'Relentless Builder (60d)' },
|
|
112
|
+
legendary_builder: { emoji: '\ud83d\udc51', label: 'Legendary Builder (100d)' }
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
for (const badge of streak.badges) {
|
|
116
|
+
const info = badgeInfo[badge] || { emoji: '\ud83c\udfc5', label: badge };
|
|
117
|
+
display += `- ${info.emoji} ${info.label}\n`;
|
|
118
|
+
}
|
|
119
|
+
display += '\n';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 30-day calendar visualization
|
|
123
|
+
if (data.calendar) {
|
|
124
|
+
display += `### Last 30 Days\n`;
|
|
125
|
+
display += '`';
|
|
126
|
+
for (const day of data.calendar) {
|
|
127
|
+
display += day.active ? '\u25a0' : '\u25a1';
|
|
128
|
+
}
|
|
129
|
+
display += '`\n';
|
|
130
|
+
display += '_(\u25a0 = active day)_\n';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Tip for maintaining streak
|
|
134
|
+
if (isOwnStreak && !streak.isActiveToday) {
|
|
135
|
+
display += `\n---\n**Tip:** Ship something or post to the board to record today's activity!`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { display };
|
|
139
|
+
|
|
140
|
+
} catch (e) {
|
|
141
|
+
return {
|
|
142
|
+
display: `## Building Streak\n\n\u274c **Failed to load**\n\nError: ${e.message}`
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { definition, handler };
|