slashvibe-mcp 0.2.2 → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +58 -40
- package/config.js +171 -3
- package/index.js +136 -16
- package/intelligence/index.js +38 -0
- package/intelligence/infer.js +316 -0
- package/intelligence/patterns.js +651 -0
- package/intelligence/proactive.js +358 -0
- package/intelligence/serendipity.js +306 -0
- package/notify.js +141 -18
- package/package.json +8 -4
- package/presence.js +5 -1
- package/protocol/index.js +88 -1
- package/protocol/telegram-commands.js +199 -0
- package/store/api.js +360 -25
- package/store/index.js +7 -7
- package/store/local.js +67 -11
- package/store/profiles.js +287 -0
- package/store/reservations.js +321 -0
- package/store/skills.js +378 -0
- package/tools/_actions.js +270 -14
- package/tools/_connection-queue.js +257 -0
- package/tools/_discovery-enhanced.js +290 -0
- package/tools/_discovery.js +346 -0
- package/tools/_proactive-discovery.js +301 -0
- package/tools/admin-inbox.js +218 -0
- package/tools/agent-treasury.js +288 -0
- package/tools/agents.js +122 -0
- package/tools/arcade.js +173 -0
- package/tools/artifact-create.js +236 -0
- package/tools/artifact-view.js +174 -0
- package/tools/ask-expert.js +160 -0
- package/tools/auto-suggest-connections.js +304 -0
- package/tools/away.js +68 -0
- package/tools/back.js +51 -0
- package/tools/become-expert.js +150 -0
- package/tools/bootstrap-skills.js +231 -0
- package/tools/bridge-dashboard.js +342 -0
- package/tools/bridge-health.js +400 -0
- package/tools/bridge-live.js +384 -0
- package/tools/bridges.js +383 -0
- package/tools/bye.js +4 -0
- package/tools/collaborative-drawing.js +286 -0
- package/tools/colorguess.js +281 -0
- package/tools/crossword.js +369 -0
- package/tools/discover-insights.js +379 -0
- package/tools/discover-momentum.js +256 -0
- package/tools/discover.js +395 -0
- package/tools/discovery-analytics.js +345 -0
- package/tools/discovery-auto-suggest.js +275 -0
- package/tools/discovery-bootstrap.js +267 -0
- package/tools/discovery-daily.js +375 -0
- package/tools/discovery-dashboard.js +385 -0
- package/tools/discovery-digest.js +314 -0
- package/tools/discovery-hub.js +357 -0
- package/tools/discovery-insights.js +384 -0
- package/tools/discovery-momentum.js +281 -0
- package/tools/discovery-monitor.js +319 -0
- package/tools/discovery-proactive.js +300 -0
- package/tools/dm.js +62 -9
- package/tools/draw.js +317 -0
- package/tools/drawing.js +310 -0
- package/tools/echo.js +16 -0
- package/tools/farcaster.js +307 -0
- package/tools/feed.js +196 -0
- package/tools/game.js +218 -110
- package/tools/games-catalog.js +376 -0
- package/tools/games.js +313 -0
- package/tools/genesis.js +233 -0
- package/tools/guessnumber.js +194 -0
- package/tools/hangman.js +129 -0
- package/tools/help.js +269 -0
- package/tools/idea.js +210 -0
- package/tools/inbox.js +148 -25
- package/tools/init.js +651 -33
- package/tools/insights.js +123 -0
- package/tools/invite.js +142 -21
- package/tools/l2-bridge.js +272 -0
- package/tools/l2-status.js +217 -0
- package/tools/l2.js +206 -0
- package/tools/migrate.js +156 -0
- package/tools/mint.js +377 -0
- package/tools/multiplayer-game.js +275 -0
- package/tools/multiplayer-tictactoe.js +303 -0
- package/tools/mute.js +97 -0
- package/tools/notifications.js +415 -0
- package/tools/observe.js +200 -0
- package/tools/onboarding.js +147 -0
- package/tools/open.js +14 -2
- package/tools/party-game.js +314 -0
- package/tools/presence-agent.js +167 -0
- package/tools/profile.js +219 -0
- package/tools/pulse.js +218 -0
- package/tools/react.js +4 -0
- package/tools/release.js +83 -0
- package/tools/report.js +109 -0
- package/tools/reputation.js +175 -0
- package/tools/request.js +217 -0
- package/tools/reservations.js +116 -0
- package/tools/reserve.js +111 -0
- package/tools/riddle.js +240 -0
- package/tools/run-bootstrap.js +69 -0
- package/tools/settings.js +112 -0
- package/tools/ship.js +182 -0
- package/tools/shipback.js +326 -0
- package/tools/skills-analytics.js +349 -0
- package/tools/skills-bootstrap.js +301 -0
- package/tools/skills-dashboard.js +268 -0
- package/tools/skills-exchange.js +342 -0
- package/tools/skills.js +380 -0
- package/tools/smart-intro.js +353 -0
- package/tools/social-inbox.js +326 -69
- package/tools/social-post.js +251 -66
- package/tools/social-processor.js +445 -0
- package/tools/solo-game.js +390 -0
- package/tools/start.js +205 -83
- package/tools/storybuilder.js +331 -0
- package/tools/suggest-tags.js +186 -0
- package/tools/tag-suggestions.js +257 -0
- package/tools/telegram-bot.js +183 -0
- package/tools/telegram-setup.js +214 -0
- package/tools/tictactoe.js +155 -0
- package/tools/tip.js +120 -0
- package/tools/token.js +103 -0
- package/tools/twentyquestions.js +143 -0
- package/tools/wallet.js +127 -0
- package/tools/webhook-test.js +388 -0
- package/tools/who.js +118 -25
- package/tools/wordassociation.js +247 -0
- package/tools/workshop-buddy.js +394 -0
- package/tools/workshop.js +327 -0
- package/version.json +12 -3
- package/tools/board.js +0 -130
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Profiles Store — Persistent user profiles with interests, tags, and projects
|
|
3
|
+
*
|
|
4
|
+
* Stores rich user data for matchmaking:
|
|
5
|
+
* - What they're building
|
|
6
|
+
* - Interests (broad topics they care about)
|
|
7
|
+
* - Tags (specific skills/technologies)
|
|
8
|
+
* - Activity patterns
|
|
9
|
+
* - Connection history
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const config = require('../config');
|
|
15
|
+
|
|
16
|
+
const PROFILES_FILE = path.join(config.VIBE_DIR, 'profiles.json');
|
|
17
|
+
|
|
18
|
+
// Load all profiles from disk
|
|
19
|
+
function loadProfiles() {
|
|
20
|
+
try {
|
|
21
|
+
if (fs.existsSync(PROFILES_FILE)) {
|
|
22
|
+
const data = fs.readFileSync(PROFILES_FILE, 'utf8');
|
|
23
|
+
return JSON.parse(data);
|
|
24
|
+
}
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.warn('Failed to load profiles:', e.message);
|
|
27
|
+
}
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Save all profiles to disk
|
|
32
|
+
function saveProfiles(profiles) {
|
|
33
|
+
try {
|
|
34
|
+
fs.writeFileSync(PROFILES_FILE, JSON.stringify(profiles, null, 2));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error('Failed to save profiles:', e.message);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Get a single user's profile
|
|
41
|
+
async function getProfile(handle) {
|
|
42
|
+
const profiles = loadProfiles();
|
|
43
|
+
const key = handle.toLowerCase().replace('@', '');
|
|
44
|
+
|
|
45
|
+
return profiles[key] || {
|
|
46
|
+
handle: key,
|
|
47
|
+
building: null,
|
|
48
|
+
interests: [],
|
|
49
|
+
tags: [],
|
|
50
|
+
lastSeen: null,
|
|
51
|
+
firstSeen: null,
|
|
52
|
+
connections: [],
|
|
53
|
+
ships: []
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Update user profile
|
|
58
|
+
async function updateProfile(handle, updates) {
|
|
59
|
+
const profiles = loadProfiles();
|
|
60
|
+
const key = handle.toLowerCase().replace('@', '');
|
|
61
|
+
|
|
62
|
+
const existing = profiles[key] || {
|
|
63
|
+
handle: key,
|
|
64
|
+
building: null,
|
|
65
|
+
interests: [],
|
|
66
|
+
tags: [],
|
|
67
|
+
lastSeen: null,
|
|
68
|
+
firstSeen: null,
|
|
69
|
+
connections: [],
|
|
70
|
+
ships: []
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Merge updates
|
|
74
|
+
const updated = { ...existing, ...updates };
|
|
75
|
+
|
|
76
|
+
// Ensure arrays are properly formatted
|
|
77
|
+
if (updates.interests) {
|
|
78
|
+
updated.interests = Array.isArray(updates.interests)
|
|
79
|
+
? updates.interests
|
|
80
|
+
: updates.interests.split(',').map(s => s.trim()).filter(s => s);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (updates.tags) {
|
|
84
|
+
updated.tags = Array.isArray(updates.tags)
|
|
85
|
+
? updates.tags
|
|
86
|
+
: updates.tags.split(',').map(s => s.trim()).filter(s => s);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Update timestamps
|
|
90
|
+
updated.lastSeen = Date.now();
|
|
91
|
+
if (!existing.firstSeen) {
|
|
92
|
+
updated.firstSeen = Date.now();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
profiles[key] = updated;
|
|
96
|
+
saveProfiles(profiles);
|
|
97
|
+
|
|
98
|
+
return updated;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Get all profiles
|
|
102
|
+
async function getAllProfiles() {
|
|
103
|
+
const profiles = loadProfiles();
|
|
104
|
+
return Object.values(profiles);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Record a connection between users
|
|
108
|
+
async function recordConnection(from, to, reason) {
|
|
109
|
+
const profiles = loadProfiles();
|
|
110
|
+
const fromKey = from.toLowerCase().replace('@', '');
|
|
111
|
+
const toKey = to.toLowerCase().replace('@', '');
|
|
112
|
+
|
|
113
|
+
const connection = {
|
|
114
|
+
timestamp: Date.now(),
|
|
115
|
+
reason,
|
|
116
|
+
suggested_by: 'discovery-agent'
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Add to both profiles
|
|
120
|
+
if (!profiles[fromKey]) profiles[fromKey] = createEmptyProfile(fromKey);
|
|
121
|
+
if (!profiles[toKey]) profiles[toKey] = createEmptyProfile(toKey);
|
|
122
|
+
|
|
123
|
+
if (!profiles[fromKey].connections) profiles[fromKey].connections = [];
|
|
124
|
+
if (!profiles[toKey].connections) profiles[toKey].connections = [];
|
|
125
|
+
|
|
126
|
+
profiles[fromKey].connections.push({ handle: toKey, ...connection });
|
|
127
|
+
profiles[toKey].connections.push({ handle: fromKey, ...connection });
|
|
128
|
+
|
|
129
|
+
saveProfiles(profiles);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Record when someone ships something
|
|
133
|
+
async function recordShip(handle, what) {
|
|
134
|
+
const profiles = loadProfiles();
|
|
135
|
+
const key = handle.toLowerCase().replace('@', '');
|
|
136
|
+
|
|
137
|
+
if (!profiles[key]) {
|
|
138
|
+
profiles[key] = createEmptyProfile(key);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!profiles[key].ships) {
|
|
142
|
+
profiles[key].ships = [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
profiles[key].ships.unshift({
|
|
146
|
+
what,
|
|
147
|
+
timestamp: Date.now()
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Keep only last 10 ships
|
|
151
|
+
profiles[key].ships = profiles[key].ships.slice(0, 10);
|
|
152
|
+
|
|
153
|
+
saveProfiles(profiles);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Update last seen time
|
|
157
|
+
async function updateLastSeen(handle) {
|
|
158
|
+
const profiles = loadProfiles();
|
|
159
|
+
const key = handle.toLowerCase().replace('@', '');
|
|
160
|
+
|
|
161
|
+
if (profiles[key]) {
|
|
162
|
+
profiles[key].lastSeen = Date.now();
|
|
163
|
+
saveProfiles(profiles);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Get connection history between two users
|
|
168
|
+
async function getConnectionHistory(handle1, handle2) {
|
|
169
|
+
const profile = await getProfile(handle1);
|
|
170
|
+
const key2 = handle2.toLowerCase().replace('@', '');
|
|
171
|
+
|
|
172
|
+
return profile.connections?.filter(c => c.handle === key2) || [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Check if users have been connected before
|
|
176
|
+
async function hasBeenConnected(handle1, handle2) {
|
|
177
|
+
const history = await getConnectionHistory(handle1, handle2);
|
|
178
|
+
return history.length > 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Get users by interest
|
|
182
|
+
async function getUsersByInterest(interest) {
|
|
183
|
+
const profiles = await getAllProfiles();
|
|
184
|
+
const searchTerm = interest.toLowerCase();
|
|
185
|
+
|
|
186
|
+
return profiles.filter(p =>
|
|
187
|
+
p.interests?.some(i => i.toLowerCase().includes(searchTerm))
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Get users by tag
|
|
192
|
+
async function getUsersByTag(tag) {
|
|
193
|
+
const profiles = await getAllProfiles();
|
|
194
|
+
const searchTerm = tag.toLowerCase();
|
|
195
|
+
|
|
196
|
+
return profiles.filter(p =>
|
|
197
|
+
p.tags?.some(t => t.toLowerCase().includes(searchTerm))
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Get trending interests
|
|
202
|
+
async function getTrendingInterests() {
|
|
203
|
+
const profiles = await getAllProfiles();
|
|
204
|
+
const interestCount = {};
|
|
205
|
+
|
|
206
|
+
for (const profile of profiles) {
|
|
207
|
+
if (profile.interests) {
|
|
208
|
+
for (const interest of profile.interests) {
|
|
209
|
+
interestCount[interest] = (interestCount[interest] || 0) + 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return Object.entries(interestCount)
|
|
215
|
+
.sort(([,a], [,b]) => b - a)
|
|
216
|
+
.slice(0, 10)
|
|
217
|
+
.map(([interest, count]) => ({ interest, count }));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Get trending tags
|
|
221
|
+
async function getTrendingTags() {
|
|
222
|
+
const profiles = await getAllProfiles();
|
|
223
|
+
const tagCount = {};
|
|
224
|
+
|
|
225
|
+
for (const profile of profiles) {
|
|
226
|
+
if (profile.tags) {
|
|
227
|
+
for (const tag of profile.tags) {
|
|
228
|
+
tagCount[tag] = (tagCount[tag] || 0) + 1;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return Object.entries(tagCount)
|
|
234
|
+
.sort(([,a], [,b]) => b - a)
|
|
235
|
+
.slice(0, 15)
|
|
236
|
+
.map(([tag, count]) => ({ tag, count }));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Create empty profile structure
|
|
240
|
+
function createEmptyProfile(handle) {
|
|
241
|
+
return {
|
|
242
|
+
handle,
|
|
243
|
+
building: null,
|
|
244
|
+
interests: [],
|
|
245
|
+
tags: [],
|
|
246
|
+
lastSeen: Date.now(),
|
|
247
|
+
firstSeen: Date.now(),
|
|
248
|
+
connections: [],
|
|
249
|
+
ships: []
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Clean up old profiles (for maintenance)
|
|
254
|
+
async function cleanupOldProfiles(daysThreshold = 30) {
|
|
255
|
+
const profiles = loadProfiles();
|
|
256
|
+
const cutoff = Date.now() - (daysThreshold * 24 * 60 * 60 * 1000);
|
|
257
|
+
|
|
258
|
+
let cleaned = 0;
|
|
259
|
+
for (const [key, profile] of Object.entries(profiles)) {
|
|
260
|
+
if (profile.lastSeen && profile.lastSeen < cutoff) {
|
|
261
|
+
delete profiles[key];
|
|
262
|
+
cleaned++;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (cleaned > 0) {
|
|
267
|
+
saveProfiles(profiles);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return cleaned;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
getProfile,
|
|
275
|
+
updateProfile,
|
|
276
|
+
getAllProfiles,
|
|
277
|
+
recordConnection,
|
|
278
|
+
recordShip,
|
|
279
|
+
updateLastSeen,
|
|
280
|
+
getConnectionHistory,
|
|
281
|
+
hasBeenConnected,
|
|
282
|
+
getUsersByInterest,
|
|
283
|
+
getUsersByTag,
|
|
284
|
+
getTrendingInterests,
|
|
285
|
+
getTrendingTags,
|
|
286
|
+
cleanupOldProfiles
|
|
287
|
+
};
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reservations Store — Local-first file reservations
|
|
3
|
+
*
|
|
4
|
+
* Advisory locks to signal edit intent and reduce conflicts.
|
|
5
|
+
* Stored locally in ~/.vibe/reservations/
|
|
6
|
+
*
|
|
7
|
+
* Key behaviors:
|
|
8
|
+
* - Scope auto-detected from git remote, fallback to cwd
|
|
9
|
+
* - Paths are relative to scope
|
|
10
|
+
* - TTL is client-enforced (check expires_ts on read)
|
|
11
|
+
* - Warn on conflict (overlapping exclusive paths), don't block
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { execSync } = require('child_process');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
|
|
19
|
+
const RESERVATIONS_DIR = path.join(process.env.HOME, '.vibe', 'reservations');
|
|
20
|
+
const ACTIVE_FILE = path.join(RESERVATIONS_DIR, 'active.jsonl');
|
|
21
|
+
const HISTORY_FILE = path.join(RESERVATIONS_DIR, 'history.jsonl');
|
|
22
|
+
const INDEX_FILE = path.join(RESERVATIONS_DIR, 'index.json');
|
|
23
|
+
|
|
24
|
+
// Ensure directories exist
|
|
25
|
+
function ensureDir() {
|
|
26
|
+
if (!fs.existsSync(RESERVATIONS_DIR)) {
|
|
27
|
+
fs.mkdirSync(RESERVATIONS_DIR, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Generate reservation ID: rsv-{nanoid}
|
|
32
|
+
function generateId() {
|
|
33
|
+
return 'rsv-' + crypto.randomBytes(4).toString('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Get git remote URL for scope
|
|
37
|
+
function getGitRemote() {
|
|
38
|
+
try {
|
|
39
|
+
const remote = execSync('git remote get-url origin 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
40
|
+
// Normalize: git@github.com:user/repo.git → github.com/user/repo
|
|
41
|
+
if (remote.startsWith('git@')) {
|
|
42
|
+
return remote.replace('git@', '').replace(':', '/').replace('.git', '');
|
|
43
|
+
}
|
|
44
|
+
// https://github.com/user/repo.git → github.com/user/repo
|
|
45
|
+
if (remote.startsWith('https://')) {
|
|
46
|
+
return remote.replace('https://', '').replace('.git', '');
|
|
47
|
+
}
|
|
48
|
+
return remote;
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Get current working directory name as fallback scope
|
|
55
|
+
function getLocalScope() {
|
|
56
|
+
try {
|
|
57
|
+
const root = execSync('git rev-parse --show-toplevel 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
58
|
+
return `local:${path.basename(root)}`;
|
|
59
|
+
} catch (e) {
|
|
60
|
+
return `local:${path.basename(process.cwd())}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Get scope for reservations
|
|
65
|
+
function getScope() {
|
|
66
|
+
const remote = getGitRemote();
|
|
67
|
+
if (remote) {
|
|
68
|
+
return `repo:${remote}`;
|
|
69
|
+
}
|
|
70
|
+
return getLocalScope();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Read all active reservations
|
|
74
|
+
function readActive() {
|
|
75
|
+
ensureDir();
|
|
76
|
+
if (!fs.existsSync(ACTIVE_FILE)) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const content = fs.readFileSync(ACTIVE_FILE, 'utf8');
|
|
80
|
+
const reservations = content
|
|
81
|
+
.split('\n')
|
|
82
|
+
.filter(line => line.trim())
|
|
83
|
+
.map(line => {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(line);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
.filter(r => r !== null);
|
|
91
|
+
|
|
92
|
+
// Filter out expired
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
return reservations.filter(r => r.expires_ts > now && r.status === 'active');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Write active reservations (rewrite entire file)
|
|
98
|
+
function writeActive(reservations) {
|
|
99
|
+
ensureDir();
|
|
100
|
+
const content = reservations.map(r => JSON.stringify(r)).join('\n') + (reservations.length ? '\n' : '');
|
|
101
|
+
fs.writeFileSync(ACTIVE_FILE, content);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Append to history
|
|
105
|
+
function appendHistory(reservation) {
|
|
106
|
+
ensureDir();
|
|
107
|
+
fs.appendFileSync(HISTORY_FILE, JSON.stringify(reservation) + '\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Update index (for quick lookups)
|
|
111
|
+
function updateIndex(reservations) {
|
|
112
|
+
ensureDir();
|
|
113
|
+
const index = {
|
|
114
|
+
count: reservations.length,
|
|
115
|
+
byPath: {},
|
|
116
|
+
byId: {},
|
|
117
|
+
updatedAt: new Date().toISOString()
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
for (const r of reservations) {
|
|
121
|
+
index.byId[r.reservation_id] = r;
|
|
122
|
+
for (const p of r.paths) {
|
|
123
|
+
if (!index.byPath[p]) {
|
|
124
|
+
index.byPath[p] = [];
|
|
125
|
+
}
|
|
126
|
+
index.byPath[p].push(r.reservation_id);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fs.writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check for conflicts with existing reservations
|
|
134
|
+
function checkConflicts(paths, exclusive = true, scope = null) {
|
|
135
|
+
const active = readActive();
|
|
136
|
+
const targetScope = scope || getScope();
|
|
137
|
+
const conflicts = [];
|
|
138
|
+
|
|
139
|
+
for (const reservation of active) {
|
|
140
|
+
// Only check same scope
|
|
141
|
+
if (reservation.scope !== targetScope) continue;
|
|
142
|
+
// Only check exclusive reservations
|
|
143
|
+
if (!reservation.exclusive) continue;
|
|
144
|
+
|
|
145
|
+
// Check path overlap
|
|
146
|
+
for (const newPath of paths) {
|
|
147
|
+
for (const existingPath of reservation.paths) {
|
|
148
|
+
// Exact match
|
|
149
|
+
if (newPath === existingPath) {
|
|
150
|
+
conflicts.push({
|
|
151
|
+
path: newPath,
|
|
152
|
+
reservation_id: reservation.reservation_id,
|
|
153
|
+
owner: reservation.owner,
|
|
154
|
+
reason: reservation.reason,
|
|
155
|
+
expires_ts: reservation.expires_ts
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// Check if new path is under existing (existing: src/, new: src/auth.ts)
|
|
159
|
+
else if (newPath.startsWith(existingPath + '/')) {
|
|
160
|
+
conflicts.push({
|
|
161
|
+
path: newPath,
|
|
162
|
+
conflictsWith: existingPath,
|
|
163
|
+
reservation_id: reservation.reservation_id,
|
|
164
|
+
owner: reservation.owner,
|
|
165
|
+
reason: reservation.reason,
|
|
166
|
+
expires_ts: reservation.expires_ts
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
// Check if existing path is under new (existing: src/auth.ts, new: src/)
|
|
170
|
+
else if (existingPath.startsWith(newPath + '/')) {
|
|
171
|
+
conflicts.push({
|
|
172
|
+
path: existingPath,
|
|
173
|
+
conflictsWith: newPath,
|
|
174
|
+
reservation_id: reservation.reservation_id,
|
|
175
|
+
owner: reservation.owner,
|
|
176
|
+
reason: reservation.reason,
|
|
177
|
+
expires_ts: reservation.expires_ts
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return conflicts;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Create a new reservation
|
|
188
|
+
function create(owner, paths, options = {}) {
|
|
189
|
+
const {
|
|
190
|
+
ttl_seconds = 3600,
|
|
191
|
+
exclusive = true,
|
|
192
|
+
reason = null,
|
|
193
|
+
thread_id = null
|
|
194
|
+
} = options;
|
|
195
|
+
|
|
196
|
+
const scope = getScope();
|
|
197
|
+
const now = new Date();
|
|
198
|
+
const expires = new Date(now.getTime() + ttl_seconds * 1000);
|
|
199
|
+
|
|
200
|
+
// Check for conflicts
|
|
201
|
+
const conflicts = checkConflicts(paths, exclusive, scope);
|
|
202
|
+
|
|
203
|
+
const reservation = {
|
|
204
|
+
reservation_id: generateId(),
|
|
205
|
+
scope,
|
|
206
|
+
paths,
|
|
207
|
+
exclusive,
|
|
208
|
+
reason,
|
|
209
|
+
thread_id,
|
|
210
|
+
owner,
|
|
211
|
+
status: 'active',
|
|
212
|
+
ttl_seconds,
|
|
213
|
+
issued_ts: now.toISOString(),
|
|
214
|
+
expires_ts: expires.toISOString()
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Add to active
|
|
218
|
+
const active = readActive();
|
|
219
|
+
active.push(reservation);
|
|
220
|
+
writeActive(active);
|
|
221
|
+
updateIndex(active);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
reservation,
|
|
225
|
+
conflicts: conflicts.length > 0 ? conflicts : null,
|
|
226
|
+
warning: conflicts.length > 0 ? `${conflicts.length} conflicting reservation(s) found` : null
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Release a reservation
|
|
231
|
+
function release(reservation_id, owner = null) {
|
|
232
|
+
const active = readActive();
|
|
233
|
+
const index = active.findIndex(r => r.reservation_id === reservation_id);
|
|
234
|
+
|
|
235
|
+
if (index === -1) {
|
|
236
|
+
return { success: false, error: 'not_found', message: 'Reservation not found or already expired' };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const reservation = active[index];
|
|
240
|
+
|
|
241
|
+
// Optional owner check
|
|
242
|
+
if (owner && reservation.owner !== owner) {
|
|
243
|
+
return { success: false, error: 'not_owner', message: 'You are not the owner of this reservation' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Mark as released and move to history
|
|
247
|
+
reservation.status = 'released';
|
|
248
|
+
reservation.released_ts = new Date().toISOString();
|
|
249
|
+
appendHistory(reservation);
|
|
250
|
+
|
|
251
|
+
// Remove from active
|
|
252
|
+
active.splice(index, 1);
|
|
253
|
+
writeActive(active);
|
|
254
|
+
updateIndex(active);
|
|
255
|
+
|
|
256
|
+
return { success: true, reservation };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// List reservations
|
|
260
|
+
function list(options = {}) {
|
|
261
|
+
const { active_only = true, path_filter = null, scope_filter = null } = options;
|
|
262
|
+
|
|
263
|
+
let reservations = readActive();
|
|
264
|
+
|
|
265
|
+
// Filter by scope
|
|
266
|
+
if (scope_filter) {
|
|
267
|
+
reservations = reservations.filter(r => r.scope === scope_filter);
|
|
268
|
+
} else {
|
|
269
|
+
// Default: only show current scope
|
|
270
|
+
const currentScope = getScope();
|
|
271
|
+
reservations = reservations.filter(r => r.scope === currentScope);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Filter by path
|
|
275
|
+
if (path_filter) {
|
|
276
|
+
reservations = reservations.filter(r =>
|
|
277
|
+
r.paths.some(p => p.includes(path_filter) || path_filter.includes(p))
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return reservations;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Get a single reservation by ID
|
|
285
|
+
function get(reservation_id) {
|
|
286
|
+
const active = readActive();
|
|
287
|
+
return active.find(r => r.reservation_id === reservation_id) || null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Cleanup expired reservations (called periodically)
|
|
291
|
+
function cleanup() {
|
|
292
|
+
const active = readActive();
|
|
293
|
+
const now = new Date().toISOString();
|
|
294
|
+
const expired = active.filter(r => r.expires_ts <= now);
|
|
295
|
+
const remaining = active.filter(r => r.expires_ts > now);
|
|
296
|
+
|
|
297
|
+
// Move expired to history
|
|
298
|
+
for (const r of expired) {
|
|
299
|
+
r.status = 'expired';
|
|
300
|
+
r.expired_ts = now;
|
|
301
|
+
appendHistory(r);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (expired.length > 0) {
|
|
305
|
+
writeActive(remaining);
|
|
306
|
+
updateIndex(remaining);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { expired: expired.length, remaining: remaining.length };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
create,
|
|
314
|
+
release,
|
|
315
|
+
list,
|
|
316
|
+
get,
|
|
317
|
+
getScope,
|
|
318
|
+
checkConflicts,
|
|
319
|
+
cleanup,
|
|
320
|
+
generateId
|
|
321
|
+
};
|