slashvibe-mcp 0.5.7 → 0.5.9

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
@@ -71,6 +71,7 @@ function inferPromptFromArgs(toolName, args) {
71
71
  case 'reservations': return 'list reservations';
72
72
  case 'solo_game': return `play ${args.game || 'game'}`;
73
73
  case 'game': return `play ${args.game || 'game'} with ${handle}`;
74
+ case 'poem': return args.line ? `add a line to poem with ${handle}` : `emoji poem with ${handle}`;
74
75
  case 'multiplayer_game': return `multiplayer ${args.game || 'game'}`;
75
76
  case 'drawing': return args.action ? `drawing ${args.action}` : 'collaborative drawing';
76
77
  case 'crossword': return `crossword ${args.action || 'daily'}`;
@@ -255,6 +256,11 @@ const coreTools = {
255
256
  vibe_ship: require('./tools/ship'),
256
257
  vibe_feed: require('./tools/feed'),
257
258
 
259
+ // Play — the original January magic: shared experiences over the DM transport
260
+ vibe_game: require('./tools/game'),
261
+ vibe_poem: require('./tools/poem'),
262
+ vibe_corpse: require('./tools/corpse'),
263
+
258
264
  // Matrix rooms — multiplayer
259
265
  vibe_matrix_rooms: require('./tools/matrix-rooms'),
260
266
  vibe_matrix_read: require('./tools/matrix-read'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
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",
package/store/api.js CHANGED
@@ -694,15 +694,22 @@ async function getThread(myHandle, theirHandle) {
694
694
  console.error('[getThread] V2 failed, falling back to v1');
695
695
  }
696
696
 
697
- // V1 fallback
697
+ // V1 fallback — live /api/messages returns { messages: [...] } with snake_case
698
+ // fields (body, created_at, source_client). Older builds returned { thread: [...] }
699
+ // with (text, createdAt). Accept BOTH shapes so the read path survives whichever
700
+ // the server sends — otherwise payload-bearing DMs (games, poems) silently vanish
701
+ // and every turn reads as a fresh game. This is the path the game/poem tools rely
702
+ // on while the v2 /threads/:id detail route is unavailable.
698
703
  const result = await request('GET', `/api/messages?user=${myHandle}&with=${theirHandle}`);
699
- return (result.thread || []).map(m => ({
704
+ const rows = result.messages || result.thread || [];
705
+ return rows.map(m => ({
700
706
  from: m.from,
701
707
  isAgent: m.isAgent || m.is_agent || false, // Support both naming conventions
702
- body: m.text,
708
+ body: m.body ?? m.text,
703
709
  payload: m.payload || null,
704
- timestamp: new Date(m.createdAt).getTime(),
705
- direction: m.direction
710
+ source_client: m.source_client || null,
711
+ timestamp: new Date(m.created_at || m.createdAt).getTime(),
712
+ direction: m.direction || (m.from === myHandle ? 'sent' : 'received'),
706
713
  }));
707
714
  } catch (e) {
708
715
  console.error('Thread failed:', e.message);
@@ -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/poem.js ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * vibe poem — Write an emoji poem together, one line at a time
3
+ *
4
+ * The original January /vibe magic: two people co-authoring something small
5
+ * and playful inside Claude Code, passed back and forth over the DM transport.
6
+ * Each turn adds a line; the poem state travels in the message payload (durable
7
+ * via the Postgres payload column), exactly like `vibe game`.
8
+ */
9
+
10
+ const config = require('../config');
11
+ const store = require('../store');
12
+ const { requireInit, normalizeHandle } = require('./_shared');
13
+
14
+ // Poems seal automatically at this many lines (either player can seal earlier).
15
+ const MAX_LINES = 8;
16
+
17
+ /**
18
+ * Find the most recent poem state in a DM thread.
19
+ */
20
+ function getPoemState(thread) {
21
+ for (let i = thread.length - 1; i >= 0; i--) {
22
+ const p = thread[i].payload;
23
+ if (p && p.type === 'poem' && p.state) return p.state;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /**
29
+ * Render a poem for the terminal.
30
+ */
31
+ function renderPoem(state, { withMe, them } = {}) {
32
+ const lines = state.lines || [];
33
+ let out = '📜 **emoji poem';
34
+ if (state.title) out += ` — ${state.title}`;
35
+ out += '**\n\n';
36
+ if (!lines.length) {
37
+ out += '_(blank page — add the first line)_\n';
38
+ } else {
39
+ out += '```\n';
40
+ for (const l of lines) out += `${l.text}\n`;
41
+ out += '```\n';
42
+ out += `\n_by ${uniqueAuthors(lines).map((a) => '@' + a).join(' & ')}_\n`;
43
+ }
44
+ return out;
45
+ }
46
+
47
+ function uniqueAuthors(lines) {
48
+ const seen = [];
49
+ for (const l of lines) if (!seen.includes(l.author)) seen.push(l.author);
50
+ return seen;
51
+ }
52
+
53
+ /**
54
+ * Whose turn is it? Alternate off the last line's author. If the last line was
55
+ * mine, it's their turn; if theirs (or empty), it's mine.
56
+ */
57
+ function isMyTurn(state, me) {
58
+ const lines = state.lines || [];
59
+ if (!lines.length) return true; // whoever opens goes first
60
+ return lines[lines.length - 1].author !== me;
61
+ }
62
+
63
+ async function postPoemToBoard(state, authors) {
64
+ const API_URL = process.env.VIBE_API_URL || 'https://www.slashvibe.dev';
65
+ const body = (state.lines || []).map((l) => l.text).join(' / ');
66
+ try {
67
+ await fetch(`${API_URL}/api/board`, {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({
71
+ author: 'echo',
72
+ content: `${authors.map((a) => '@' + a).join(' & ')} finished an emoji poem: ${body}`,
73
+ category: 'general',
74
+ }),
75
+ });
76
+ } catch (e) {
77
+ console.error('[poem] Failed to post to board:', e.message);
78
+ }
79
+ }
80
+
81
+ const definition = {
82
+ name: 'vibe_poem',
83
+ description:
84
+ 'Write an emoji poem together, one line at a time. Pass it back and forth with someone — each turn adds a line. `vibe poem @handle --line "🌊🌙 the tide remembers"`',
85
+ inputSchema: {
86
+ type: 'object',
87
+ properties: {
88
+ handle: {
89
+ type: 'string',
90
+ description: 'Who to write with (e.g., @solienne)',
91
+ },
92
+ line: {
93
+ type: 'string',
94
+ description: 'The line to add (emoji-forward, but any text welcome)',
95
+ },
96
+ title: {
97
+ type: 'string',
98
+ description: 'Optional title, only used when starting a new poem',
99
+ },
100
+ done: {
101
+ type: 'boolean',
102
+ description: 'Seal the poem as finished after adding your line',
103
+ },
104
+ },
105
+ required: ['handle'],
106
+ },
107
+ };
108
+
109
+ async function handler(args) {
110
+ const initCheck = requireInit();
111
+ if (initCheck) return initCheck;
112
+
113
+ const { line, title, done } = args;
114
+ const me = config.getHandle();
115
+ const them = normalizeHandle(args.handle);
116
+
117
+ if (them === me) {
118
+ return { display: "You can't write a poem with yourself. Invite someone: `vibe poem @handle`." };
119
+ }
120
+
121
+ const thread = await store.getThread(me, them);
122
+ let state = getPoemState(thread);
123
+
124
+ // No line provided → show the current poem (or start a fresh one).
125
+ if (!line) {
126
+ if (!state) {
127
+ const newState = { lines: [], title: title || null, done: false, startedBy: me };
128
+ await store.sendMessage(
129
+ me,
130
+ them,
131
+ `Let's write an emoji poem together 📜 Add a line: \`vibe poem @${me} --line "…"\``,
132
+ 'dm',
133
+ { type: 'poem', state: newState }
134
+ );
135
+ return {
136
+ display:
137
+ `## New emoji poem with @${them}\n\n${renderPoem(newState)}\n` +
138
+ `Add the first line: \`vibe poem @${them} --line "🌱 a small green start"\``,
139
+ };
140
+ }
141
+
142
+ // Show existing poem.
143
+ const authors = uniqueAuthors(state.lines);
144
+ let display = `## emoji poem with @${them}\n\n${renderPoem(state)}\n`;
145
+ if (state.done) {
146
+ display += `\n✨ Finished. Start another: \`vibe poem @${them} --line "…"\``;
147
+ } else if (isMyTurn(state, me)) {
148
+ display += `\nYour turn — add a line: \`vibe poem @${them} --line "…"\``;
149
+ } else {
150
+ display += `\nWaiting for @${them} to add the next line…`;
151
+ }
152
+ return { display };
153
+ }
154
+
155
+ // Adding a line.
156
+ if (!state) {
157
+ state = { lines: [], title: title || null, done: false, startedBy: me };
158
+ }
159
+
160
+ if (state.done) {
161
+ return {
162
+ display: `This poem is finished. Start a new one: \`vibe poem @${them} --line "…"\``,
163
+ };
164
+ }
165
+
166
+ if (!isMyTurn(state, me)) {
167
+ return {
168
+ display:
169
+ `It's @${them}'s turn to add a line.\n\n${renderPoem(state)}\n` +
170
+ `Nudge them, or wait for their line.`,
171
+ };
172
+ }
173
+
174
+ const text = String(line).trim();
175
+ if (!text) return { display: 'Give me a line to add: `vibe poem @handle --line "…"`' };
176
+ if (text.length > 200) return { display: 'Keep each line under 200 characters.' };
177
+
178
+ const newLines = [...(state.lines || []), { author: me, text }];
179
+ const shouldSeal = done === true || newLines.length >= MAX_LINES;
180
+ const newState = {
181
+ ...state,
182
+ lines: newLines,
183
+ done: shouldSeal,
184
+ };
185
+
186
+ const authors = uniqueAuthors(newLines);
187
+ let message;
188
+ if (shouldSeal) {
189
+ message = `Added a line and sealed our poem 📜✨\n\n${newLines.map((l) => l.text).join('\n')}`;
190
+ if (authors.length > 1) postPoemToBoard(newState, authors);
191
+ } else if (isMyTurn(newState, them)) {
192
+ message = `Added a line — your turn 📜\n"${text}"`;
193
+ } else {
194
+ message = `Added a line 📜\n"${text}"`;
195
+ }
196
+
197
+ await store.sendMessage(me, them, message, 'dm', { type: 'poem', state: newState });
198
+
199
+ let display = `## emoji poem with @${them}\n\n${renderPoem(newState)}\n`;
200
+ if (shouldSeal) {
201
+ display += `\n✨ Poem sealed${newLines.length >= MAX_LINES ? ` (${MAX_LINES} lines)` : ''}. Start another anytime.`;
202
+ } else {
203
+ display += `\nSent to @${them}. Waiting for their line…`;
204
+ }
205
+ return { display };
206
+ }
207
+
208
+ module.exports = { definition, handler };
package/version.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
- "version": "0.5.6",
3
- "updated": "2026-06-30",
4
- "changelog": "Come back to what you missed. Returning to /vibe now shows your unread count right away, and if we don't have an email to reach you at, we offer to arm the return-loop ping at the moment it matters when a DM just landed while you were away.",
2
+ "version": "0.5.9",
3
+ "updated": "2026-07-03",
4
+ "changelog": "New way to play together: the exquisite corpse. `vibe_corpse @handle` starts a surrealist story you build one line at a time but you only ever see the LAST line, never the whole thing, until someone seals it and the full corpse is revealed. Joins `vibe_game` (tic-tac-toe) and `vibe_poem` (emoji poem) as shared experiences carried over your DM thread. Ship together, play together.",
5
5
  "features": [
6
- "Returning 'already signed in' surface now shows your unread count instead of a static line",
7
- "Contextual email nudge on return: if a DM landed while you were away and we can't reach you, we offer 'vibe email' right then",
8
- "One-line, offline-only, one-click unsubscribe the return loop only pings when you're actually gone"
6
+ "vibe_corpse write an exquisite corpse with another viber: alternate lines blind to all but the last, then seal for the big reveal",
7
+ "vibe_game play tic-tac-toe with another viber, move by move, carried over your DM thread",
8
+ "vibe_poem build a collaborative emoji poem together, one line each, until someone seals it"
9
9
  ],
10
10
  "deprecated": [
11
11
  "vibe_pair — use Matrix room invites",