slashvibe-mcp 0.5.8 → 0.5.10

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/index.js CHANGED
@@ -256,9 +256,12 @@ const coreTools = {
256
256
  vibe_ship: require('./tools/ship'),
257
257
  vibe_feed: require('./tools/feed'),
258
258
 
259
- // Play — the original January magic: shared experiences over the DM transport
259
+ // Play — the original January magic: shared experiences over the DM transport.
260
+ // vibe_play is the open primitive; game/poem/corpse are hardcoded on-ramps.
261
+ vibe_play: require('./tools/play'),
260
262
  vibe_game: require('./tools/game'),
261
263
  vibe_poem: require('./tools/poem'),
264
+ vibe_corpse: require('./tools/corpse'),
262
265
 
263
266
  // Matrix rooms — multiplayer
264
267
  vibe_matrix_rooms: require('./tools/matrix-rooms'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "mcpName": "io.github.vibecodinginc/vibe",
5
5
  "description": "Social layer for Claude Code - DMs, presence, Matrix multiplayer rooms, and connection between AI-assisted developers",
6
6
  "main": "index.js",
@@ -0,0 +1,269 @@
1
+ /**
2
+ * vibe corpse — Write an exquisite corpse together, one line at a time
3
+ *
4
+ * The surrealist parlor game, ported to the terminal. Two people build a story
5
+ * (or a poem) by alternating lines — but you only ever see the LAST line the
6
+ * other person wrote, never the whole thing. When someone seals it, the full
7
+ * corpse is revealed to both, usually gloriously nonsensical.
8
+ *
9
+ * Same transport as `vibe poem` / `vibe game`: the story state travels in the
10
+ * DM message payload (durable via the Postgres payload column), passed back and
11
+ * forth over the DM thread. The only new idea here is that render hides every
12
+ * line but the last until the story is sealed.
13
+ */
14
+
15
+ const config = require('../config');
16
+ const store = require('../store');
17
+ const { requireInit, normalizeHandle } = require('./_shared');
18
+
19
+ // Corpses seal automatically at this many lines (either player can seal earlier).
20
+ const MAX_LINES = 10;
21
+
22
+ /**
23
+ * Find the most recent corpse state in a DM thread.
24
+ */
25
+ function getCorpseState(thread) {
26
+ for (let i = thread.length - 1; i >= 0; i--) {
27
+ const p = thread[i].payload;
28
+ if (p && p.type === 'corpse' && p.state) return p.state;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function uniqueAuthors(lines) {
34
+ const seen = [];
35
+ for (const l of lines) if (!seen.includes(l.author)) seen.push(l.author);
36
+ return seen;
37
+ }
38
+
39
+ /**
40
+ * Render the full, revealed corpse (only shown once sealed).
41
+ */
42
+ function renderReveal(state) {
43
+ const lines = state.lines || [];
44
+ let out = '💀 **exquisite corpse';
45
+ if (state.title) out += ` — ${state.title}`;
46
+ out += '**\n\n';
47
+ if (!lines.length) {
48
+ out += '_(empty — nothing was written)_\n';
49
+ } else {
50
+ out += '```\n';
51
+ for (const l of lines) out += `${l.text}\n`;
52
+ out += '```\n';
53
+ out += `\n_by ${uniqueAuthors(lines).map((a) => '@' + a).join(' & ')} — ${lines.length} lines_\n`;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * Render the *in-progress* corpse: the whole story stays folded, you only see
60
+ * the last line to build from. This is the whole point of the game.
61
+ */
62
+ function renderFolded(state, me) {
63
+ const lines = state.lines || [];
64
+ let out = '💀 **exquisite corpse';
65
+ if (state.title) out += ` — ${state.title}`;
66
+ out += '**\n\n';
67
+ if (!lines.length) {
68
+ out += '_(blank page — write the opening line; the rest stays hidden)_\n';
69
+ return out;
70
+ }
71
+ const last = lines[lines.length - 1];
72
+ const hiddenCount = lines.length - 1;
73
+ out += '```\n';
74
+ if (hiddenCount > 0) {
75
+ out += `… ${hiddenCount} line${hiddenCount === 1 ? '' : 's'} folded out of sight …\n`;
76
+ }
77
+ out += `${last.text}\n`;
78
+ out += '```\n';
79
+ out += `\n_only the last line shows — continue from it, blind to the rest_\n`;
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * Whose turn is it? Alternate off the last line's author. If the last line was
85
+ * mine, it's their turn; if theirs (or empty), it's mine.
86
+ */
87
+ function isMyTurn(state, me) {
88
+ const lines = state.lines || [];
89
+ if (!lines.length) return true; // whoever opens goes first
90
+ return lines[lines.length - 1].author !== me;
91
+ }
92
+
93
+ async function postCorpseToBoard(state, authors) {
94
+ const API_URL = process.env.VIBE_API_URL || 'https://www.slashvibe.dev';
95
+ const body = (state.lines || []).map((l) => l.text).join(' / ');
96
+ try {
97
+ await fetch(`${API_URL}/api/board`, {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({
101
+ author: 'echo',
102
+ content: `${authors.map((a) => '@' + a).join(' & ')} unfolded an exquisite corpse: ${body}`,
103
+ category: 'general',
104
+ }),
105
+ });
106
+ } catch (e) {
107
+ console.error('[corpse] Failed to post to board:', e.message);
108
+ }
109
+ }
110
+
111
+ const definition = {
112
+ name: 'vibe_corpse',
113
+ description:
114
+ 'Write an exquisite corpse together — a surrealist story where you only ever see the LAST line, never the whole thing, until it is sealed and revealed. Pass it back and forth: each turn adds a line, blind to what came before. `vibe corpse @handle --line "the clock swallowed its own hands"`',
115
+ inputSchema: {
116
+ type: 'object',
117
+ properties: {
118
+ handle: {
119
+ type: 'string',
120
+ description: 'Who to write with (e.g., @solienne)',
121
+ },
122
+ line: {
123
+ type: 'string',
124
+ description: 'The line to add — builds from the last visible line only',
125
+ },
126
+ title: {
127
+ type: 'string',
128
+ description: 'Optional title, only used when starting a new corpse',
129
+ },
130
+ done: {
131
+ type: 'boolean',
132
+ description: 'Seal the corpse after adding your line, revealing the whole thing to both of you',
133
+ },
134
+ reveal: {
135
+ type: 'boolean',
136
+ description: 'Show the full revealed corpse (only works once it has been sealed)',
137
+ },
138
+ },
139
+ required: ['handle'],
140
+ },
141
+ };
142
+
143
+ async function handler(args) {
144
+ const initCheck = requireInit();
145
+ if (initCheck) return initCheck;
146
+
147
+ const { line, title, done, reveal } = args;
148
+ const me = config.getHandle();
149
+ const them = normalizeHandle(args.handle);
150
+
151
+ if (them === me) {
152
+ return { display: "You can't play exquisite corpse with yourself — half the fun is the surprise. Invite someone: `vibe corpse @handle`." };
153
+ }
154
+
155
+ const thread = await store.getThread(me, them);
156
+ let state = getCorpseState(thread);
157
+
158
+ // Explicit reveal request.
159
+ if (reveal) {
160
+ if (!state) {
161
+ return { display: `No corpse with @${them} yet. Start one: \`vibe corpse @${them} --line "…"\`` };
162
+ }
163
+ if (!state.done) {
164
+ return {
165
+ display:
166
+ `That corpse isn't sealed yet — no peeking! 💀\n\n${renderFolded(state, me)}\n` +
167
+ (isMyTurn(state, me)
168
+ ? `Your turn — add a line: \`vibe corpse @${them} --line "…"\``
169
+ : `Waiting for @${them} to add the next line…`),
170
+ };
171
+ }
172
+ return { display: `## exquisite corpse with @${them}\n\n${renderReveal(state)}` };
173
+ }
174
+
175
+ // No line provided → show current status (folded), or start a fresh corpse.
176
+ if (!line) {
177
+ if (!state) {
178
+ const newState = { lines: [], title: title || null, done: false, startedBy: me };
179
+ await store.sendMessage(
180
+ me,
181
+ them,
182
+ `Let's play exquisite corpse 💀 A surrealist story where you only see my last line. Add yours: \`vibe corpse @${me} --line "…"\``,
183
+ 'dm',
184
+ { type: 'corpse', state: newState }
185
+ );
186
+ return {
187
+ display:
188
+ `## New exquisite corpse with @${them}\n\n${renderFolded(newState, me)}\n` +
189
+ `Write the opening line: \`vibe corpse @${them} --line "the moon filed a complaint"\``,
190
+ };
191
+ }
192
+
193
+ // Show existing corpse (folded unless sealed).
194
+ if (state.done) {
195
+ return {
196
+ display:
197
+ `## exquisite corpse with @${them}\n\n${renderReveal(state)}\n` +
198
+ `✨ Sealed. Start another: \`vibe corpse @${them} --line "…"\``,
199
+ };
200
+ }
201
+ let display = `## exquisite corpse with @${them}\n\n${renderFolded(state, me)}\n`;
202
+ if (isMyTurn(state, me)) {
203
+ display += `\nYour turn — add a line (blind to the rest): \`vibe corpse @${them} --line "…"\``;
204
+ } else {
205
+ display += `\nWaiting for @${them} to add the next line…`;
206
+ }
207
+ return { display };
208
+ }
209
+
210
+ // Adding a line.
211
+ if (!state) {
212
+ state = { lines: [], title: title || null, done: false, startedBy: me };
213
+ }
214
+
215
+ if (state.done) {
216
+ return {
217
+ display:
218
+ `This corpse is already sealed 💀\n\n${renderReveal(state)}\n` +
219
+ `Start a new one: \`vibe corpse @${them} --line "…"\``,
220
+ };
221
+ }
222
+
223
+ if (!isMyTurn(state, me)) {
224
+ return {
225
+ display:
226
+ `It's @${them}'s turn to add a line.\n\n${renderFolded(state, me)}\n` +
227
+ `Nudge them, or wait for their line.`,
228
+ };
229
+ }
230
+
231
+ const text = String(line).trim();
232
+ if (!text) return { display: 'Give me a line to add: `vibe corpse @handle --line "…"`' };
233
+ if (text.length > 200) return { display: 'Keep each line under 200 characters.' };
234
+
235
+ const newLines = [...(state.lines || []), { author: me, text }];
236
+ const shouldSeal = done === true || newLines.length >= MAX_LINES;
237
+ const newState = {
238
+ ...state,
239
+ lines: newLines,
240
+ done: shouldSeal,
241
+ };
242
+
243
+ const authors = uniqueAuthors(newLines);
244
+ let message;
245
+ if (shouldSeal) {
246
+ // On seal, the full corpse is revealed — send it in the DM so both sides see it.
247
+ message =
248
+ `Sealed our exquisite corpse 💀✨ Here's the whole thing, unfolded:\n\n` +
249
+ newLines.map((l) => l.text).join('\n');
250
+ if (authors.length > 1) postCorpseToBoard(newState, authors);
251
+ } else {
252
+ // Mid-game: only tell them it's their turn. Do NOT echo the story — keep it folded.
253
+ message = `Added a line to our corpse — your turn 💀 You'll only see my last line:\n"${text}"`;
254
+ }
255
+
256
+ await store.sendMessage(me, them, message, 'dm', { type: 'corpse', state: newState });
257
+
258
+ if (shouldSeal) {
259
+ let display = `## exquisite corpse with @${them}\n\n${renderReveal(newState)}\n`;
260
+ display += `\n✨ Corpse sealed${newLines.length >= MAX_LINES ? ` (${MAX_LINES} lines)` : ''} and revealed to you both. Start another anytime.`;
261
+ return { display };
262
+ }
263
+
264
+ let display = `## exquisite corpse with @${them}\n\n${renderFolded(newState, me)}\n`;
265
+ display += `\nYour line is in. Sent to @${them}, folded away — waiting for theirs…`;
266
+ return { display };
267
+ }
268
+
269
+ module.exports = { definition, handler };
package/tools/play.js ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * vibe play — the open channel for emergent, AI-improvised social play
3
+ *
4
+ * game / poem / corpse each hardcode one activity's rules. This tool hardcodes
5
+ * NONE. It is the bare primitive underneath all of them: carry an arbitrary
6
+ * `state` object back and forth over the DM thread, with a freeform `note`, and
7
+ * let the two Claude sessions invent whatever they want on top of it — chess,
8
+ * 20 questions, a co-written story, a shared drawing, a debate, a game neither
9
+ * player has a name for yet.
10
+ *
11
+ * The rules live in the conversation, not in this file. That's the whole point:
12
+ * the platform supplies the presence + the channel; the creativity is emergent
13
+ * from the humans and their Claudes. game/poem/corpse are now just friendly
14
+ * on-ramps that teach the pattern; `vibe play` is the pattern itself.
15
+ *
16
+ * Transport is identical to the others: `payload: { type:'play', ... }` on a DM,
17
+ * durable via the Postgres payload column, read back with store.getThread().
18
+ */
19
+
20
+ const config = require('../config');
21
+ const store = require('../store');
22
+ const { requireInit, normalizeHandle } = require('./_shared');
23
+
24
+ // Guardrail so a runaway state blob can't wedge the DM store. Generous — a
25
+ // chess board, a long story-so-far, or a 100-cell canvas all fit easily.
26
+ const MAX_STATE_BYTES = 24000;
27
+
28
+ /**
29
+ * Find the most recent play session in a DM thread.
30
+ */
31
+ function getPlaySession(thread) {
32
+ for (let i = thread.length - 1; i >= 0; i--) {
33
+ const p = thread[i].payload;
34
+ if (p && p.type === 'play') return p;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Accept state as a structured object/array/primitive, or a JSON string, or a
41
+ * plain string. We never force a shape — whatever the two Claudes agree on.
42
+ */
43
+ function coerceState(raw) {
44
+ if (raw === undefined || raw === null) return undefined;
45
+ if (typeof raw === 'string') {
46
+ const s = raw.trim();
47
+ if (!s) return undefined;
48
+ try {
49
+ return JSON.parse(s);
50
+ } catch {
51
+ return s; // not JSON — keep the raw string, it's a valid state too
52
+ }
53
+ }
54
+ return raw;
55
+ }
56
+
57
+ function stateBytes(state) {
58
+ if (state === undefined) return 0;
59
+ try {
60
+ return Buffer.byteLength(typeof state === 'string' ? state : JSON.stringify(state), 'utf8');
61
+ } catch {
62
+ return Infinity;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Render whatever state is, legibly, for the local Claude to reason about.
68
+ */
69
+ function renderState(state) {
70
+ if (state === undefined) return '_(no state yet — you define it)_';
71
+ if (typeof state === 'string') return '```\n' + state + '\n```';
72
+ try {
73
+ return '```json\n' + JSON.stringify(state, null, 2) + '\n```';
74
+ } catch {
75
+ return '_(unrenderable state)_';
76
+ }
77
+ }
78
+
79
+ const definition = {
80
+ name: 'vibe_play',
81
+ description:
82
+ 'The open channel for playing ANY shared activity with another viber — you and their Claude improvise the rules, this tool just carries the state. No built-in game logic. Use it for anything two sessions can invent: chess, 20 questions, a co-written story, battleship, a shared ascii drawing, a debate, a made-up game. ' +
83
+ 'Pass a freeform `state` (any JSON — the current board/story/whatever) and a `note` (a plain-language message to the other player and their Claude). ' +
84
+ 'Call it with no state to READ the current session so you can interpret it and take your turn. This is the primitive under vibe_game / vibe_poem / vibe_corpse — reach for it when the activity is anything else.',
85
+ inputSchema: {
86
+ type: 'object',
87
+ properties: {
88
+ handle: {
89
+ type: 'string',
90
+ description: 'Who to play with (e.g., @solienne)',
91
+ },
92
+ note: {
93
+ type: 'string',
94
+ description:
95
+ "A plain-language message to the other player and their Claude — what you did, whose turn it is, the rules if you're starting. This is what shows in their DM.",
96
+ },
97
+ state: {
98
+ description:
99
+ 'The current activity state, as any JSON value (object, array, or string). Whatever shape you and the other Claude agree on — a board, a story-so-far, a score, moves. Omit to just read the current state.',
100
+ },
101
+ kind: {
102
+ type: 'string',
103
+ description:
104
+ "A freeform label for the activity, e.g. 'chess', '20-questions', 'story', 'battleship'. You coin it — it's just a hint so both sides know what game this is.",
105
+ },
106
+ done: {
107
+ type: 'boolean',
108
+ description: 'Mark the activity finished after this turn.',
109
+ },
110
+ },
111
+ required: ['handle'],
112
+ },
113
+ };
114
+
115
+ async function handler(args) {
116
+ const initCheck = requireInit();
117
+ if (initCheck) return initCheck;
118
+
119
+ const me = config.getHandle();
120
+ const them = normalizeHandle(args.handle);
121
+
122
+ if (them === me) {
123
+ return { display: "You can't play with yourself here — invite someone: `vibe play @handle`." };
124
+ }
125
+
126
+ const thread = await store.getThread(me, them);
127
+ const last = getPlaySession(thread);
128
+
129
+ const incomingState = coerceState(args.state);
130
+ const note = args.note ? String(args.note).trim() : '';
131
+ const kind = (args.kind ? String(args.kind).trim() : '') || (last && last.kind) || null;
132
+ const done = args.done === true;
133
+
134
+ const hasWrite = incomingState !== undefined || note || done;
135
+
136
+ // ── READ: no write args → surface the current session for this Claude to act on.
137
+ if (!hasWrite) {
138
+ if (!last) {
139
+ return {
140
+ display:
141
+ `## play with @${them}\n\n` +
142
+ `_No shared activity going yet._\n\n` +
143
+ `Start anything — you and @${them}'s Claude make up the rules. Examples:\n` +
144
+ `\`vibe play @${them} --kind "chess" --note "you're white, your move" --state '{"board":"start"}'\`\n` +
145
+ `\`vibe play @${them} --kind "20-questions" --note "I'm thinking of something. ask away."\`\n` +
146
+ `\`vibe play @${them} --kind "story" --note "once upon a time..." --state "once upon a time"\`\n\n` +
147
+ `(For the classics there are shortcuts: \`vibe game\`, \`vibe poem\`, \`vibe corpse\`.)`,
148
+ };
149
+ }
150
+
151
+ const who = last.by === me ? 'you' : `@${last.by}`;
152
+ let display = `## play with @${them}${last.kind ? ` — ${last.kind}` : ''}\n\n`;
153
+ if (last.note) display += `last from ${who}: "${last.note}"\n\n`;
154
+ display += `state:\n${renderState(last.state)}\n`;
155
+ if (last.done) {
156
+ display += `\n✨ This one's finished. Start another with \`vibe play @${them} …\``;
157
+ } else if (last.by === me) {
158
+ display += `\n_Waiting on @${them} — they play next._`;
159
+ } else {
160
+ display +=
161
+ `\n**Your move.** Interpret the state above, decide what happens next, and send it back:\n` +
162
+ `\`vibe play @${them}${last.kind ? ` --kind "${last.kind}"` : ''} --note "…" --state '{…}'\` (add \`--done\` to finish)`;
163
+ }
164
+ return { display };
165
+ }
166
+
167
+ // ── WRITE: take a turn.
168
+ // Carry the previous state forward if the caller didn't supply a new one.
169
+ const newState = incomingState !== undefined ? incomingState : last ? last.state : undefined;
170
+
171
+ if (stateBytes(newState) > MAX_STATE_BYTES) {
172
+ return {
173
+ display: `That state is too big (>${Math.round(MAX_STATE_BYTES / 1000)}KB). Keep the shared state compact — summarize instead of accumulating raw history.`,
174
+ };
175
+ }
176
+
177
+ const payload = {
178
+ type: 'play',
179
+ kind: kind || null,
180
+ state: newState,
181
+ note: note || null,
182
+ by: me,
183
+ done,
184
+ };
185
+
186
+ // The DM body is what lands in their inbox. Lead with the note; add a light
187
+ // hint so their Claude knows to open the session and respond.
188
+ let body;
189
+ if (done) {
190
+ body = note ? `${note}\n\n(sealed our ${kind || 'game'} 🎲)` : `Sealed our ${kind || 'game'} 🎲`;
191
+ } else {
192
+ body = note || `Your move 🎲`;
193
+ body += `\n\n▸ open with \`vibe play @${me}\` to see the state and reply`;
194
+ }
195
+
196
+ await store.sendMessage(me, them, body, 'dm', payload);
197
+
198
+ let display = `## play with @${them}${kind ? ` — ${kind}` : ''}\n\n`;
199
+ display += `state:\n${renderState(newState)}\n`;
200
+ if (done) {
201
+ display += `\n✨ Sealed and sent to @${them}. Start another anytime.`;
202
+ } else {
203
+ display += `\nSent to @${them}. Waiting for their move…`;
204
+ }
205
+ return { display };
206
+ }
207
+
208
+ module.exports = { definition, handler };
package/version.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
- "version": "0.5.8",
2
+ "version": "0.5.10",
3
3
  "updated": "2026-07-03",
4
- "changelog": "The January magic is back. Play a game or write a poem with another builder right inside your terminal, turn by turn, over your DM thread. `vibe_game` for tic-tac-toe, `vibe_poem` for a collaborative emoji poem. Ship together, play together.",
4
+ "changelog": "Play anything. `vibe_play @handle` is the open channel underneath the games it carries an arbitrary state object over your DM thread and lets you and the other person's Claude improvise the rules to whatever you invent: chess, 20 questions, a co-written story, battleship, a shared drawing, a debate. No built-in game logic — the creativity is yours. `vibe_game`, `vibe_poem`, and `vibe_corpse` are still here as quick on-ramps. Ship together, play together.",
5
5
  "features": [
6
- "vibe_gameplay tic-tac-toe with another viber, move by move, carried over your DM thread",
7
- "vibe_poembuild a collaborative emoji poem together, one line each, until someone seals it",
8
- "Fixed DM thread reads so game and poem state reliably round-trips between two people"
6
+ "vibe_playthe open primitive: pass any JSON state + a note to another viber, read it back to take your turn, and improvise any activity two sessions can dream up",
7
+ "vibe_corpsewrite an exquisite corpse: alternate lines blind to all but the last, then seal for the big reveal",
8
+ "vibe_game play tic-tac-toe with another viber, move by move, over your DM thread",
9
+ "vibe_poem — build a collaborative emoji poem together, one line each"
9
10
  ],
10
11
  "deprecated": [
11
12
  "vibe_pair — use Matrix room invites",