slashvibe-mcp 0.5.12 → 0.5.14

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
@@ -249,6 +249,13 @@ const coreTools = {
249
249
  vibe_inbox: require('./tools/inbox'),
250
250
  vibe_reply: require('./tools/reply'),
251
251
 
252
+ // Received collaboration — land the newcomer mid-conversation with a topical match
253
+ vibe_intro: require('./tools/intro'),
254
+
255
+ // The Weave — attach this session's strand so Fable can bring moments INTO the
256
+ // terminal (Spec 1); "Fable holds your half" surfaces via action:'held' + vibe_start.
257
+ vibe_weave: require('./tools/weave'),
258
+
252
259
  // Return loop — get pinged about DMs you miss while away
253
260
  vibe_email: require('./tools/email'),
254
261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
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/tools/_shared.js CHANGED
@@ -284,6 +284,120 @@ function firstDmNudge(others, threads) {
284
284
  );
285
285
  }
286
286
 
287
+ // ─────────────────────────────────────────────────────────────
288
+ // Received collaboration (topical intro on onboarding)
289
+ // ─────────────────────────────────────────────────────────────
290
+
291
+ // Words too generic to signal a shared topic. Kept aligned with the
292
+ // server-side building-keyword filter in api/lib/relevancy.js.
293
+ const INTRO_STOPWORDS = new Set([
294
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'will', 'app', 'apps',
295
+ 'using', 'your', 'you', 'building', 'build', 'built', 'working', 'work',
296
+ 'make', 'making', 'some', 'into', 'just', 'like', 'want', 'need', 'trying',
297
+ 'have', 'has', 'been', 'about', 'their', 'they', 'what', 'when', 'code',
298
+ 'coding', 'stuff', 'thing', 'things', 'project', 'projects', 'feature',
299
+ 'features', 'new', 'around', 'currently', 'right', 'now', 'today', 'session',
300
+ ]);
301
+
302
+ /**
303
+ * Extract meaningful, de-duped keywords from free text for topic matching.
304
+ * Mirrors the >3-char + common-word filter used server-side so client and
305
+ * server agree on what "related" means.
306
+ */
307
+ function introKeywords(text) {
308
+ return [
309
+ ...new Set(
310
+ String(text || '')
311
+ .toLowerCase()
312
+ .replace(/[^a-z0-9\s+#.-]/g, ' ')
313
+ .split(/\s+/)
314
+ .filter((w) => w.length > 3 && !INTRO_STOPWORDS.has(w))
315
+ ),
316
+ ];
317
+ }
318
+
319
+ // The topical text we know about an online human: what they're working on
320
+ // right now + their most recent ship. Both are already public (presence + board).
321
+ function introTopicText(u) {
322
+ const ship = u && u.lastShip && u.lastShip.what ? u.lastShip.what : '';
323
+ return `${(u && u.one_liner) || ''} ${ship}`.trim();
324
+ }
325
+
326
+ // Strip quotes/backslashes and clamp — the opener is pasted inside a
327
+ // double-quoted `vibe dm @x "..."` command, so an embedded quote would close
328
+ // it early. Same paste-safety rule as pickDormantTarget.
329
+ function sanitizeSnippet(text, max = 48) {
330
+ return String(text || '')
331
+ .replace(/["\\]/g, '')
332
+ .replace(/\s+/g, ' ')
333
+ .trim()
334
+ .slice(0, max);
335
+ }
336
+
337
+ /**
338
+ * Compose a two-sided warm opener: references what the newcomer is building AND
339
+ * what the target is on, anchored to the topic they share. This is the whole
340
+ * point of "received collaboration" — the newcomer arrives already in a
341
+ * relevant conversation instead of cold-DMing a stranger.
342
+ */
343
+ function composeIntro(building, target, shared) {
344
+ const mine = sanitizeSnippet(building, 60);
345
+ // For DISPLAY use one clean phrase — what they're doing now, else their last
346
+ // ship. (introTopicText concatenates both for MATCHING, which reads badly.)
347
+ const theirRaw =
348
+ (target && target.one_liner) ||
349
+ (target && target.lastShip && target.lastShip.what) ||
350
+ '';
351
+ const theirWork = sanitizeSnippet(theirRaw, 48);
352
+ const topic = shared && shared[0];
353
+ if (theirWork && topic) {
354
+ return `hey! i just jumped into /vibe — i'm working on ${mine} and saw you're on ${theirWork}. we're both deep in ${topic}, want to compare notes?`;
355
+ }
356
+ if (theirWork) {
357
+ return `hey! i just jumped into /vibe — i'm working on ${mine} and saw you're on ${theirWork}. what are you building?`;
358
+ }
359
+ return `hey! i just jumped into /vibe — i'm working on ${mine}. what are you building?`;
360
+ }
361
+
362
+ /**
363
+ * Pick the online human whose current work / recent ship most overlaps with what
364
+ * the newcomer is building this session. Returns a topically-matched target with
365
+ * a composed two-sided opener, or null when nothing lines up (caller should fall
366
+ * back to pickDormantTarget's recency pick). Agents are already excluded upstream
367
+ * (getActiveUsers only returns active+away, never the agents lane) but we guard
368
+ * anyway.
369
+ *
370
+ * @param {Array} others - active users (caller excludes self)
371
+ * @param {string} building - the agent's one-line summary of the user's current work
372
+ * @returns {{handle, opener, shared, related}|null}
373
+ */
374
+ function pickRelatedTarget(others, building) {
375
+ const myWords = introKeywords(building);
376
+ if (myWords.length === 0) return null;
377
+ const humans = (others || []).filter((u) => u && u.handle && !u.isAgent && !u.is_agent);
378
+ let best = null;
379
+ for (const u of humans) {
380
+ const theirWords = introKeywords(introTopicText(u));
381
+ const shared = myWords.filter((w) => theirWords.includes(w));
382
+ if (shared.length === 0) continue;
383
+ const recency = new Date(u.lastSeen || u.last_seen || 0).getTime() || 0;
384
+ if (
385
+ !best ||
386
+ shared.length > best.score ||
387
+ (shared.length === best.score && recency > best.recency)
388
+ ) {
389
+ best = { user: u, score: shared.length, shared, recency };
390
+ }
391
+ }
392
+ if (!best) return null;
393
+ return {
394
+ handle: best.user.handle,
395
+ shared: best.shared,
396
+ opener: composeIntro(building, best.user, best.shared),
397
+ related: true,
398
+ };
399
+ }
400
+
287
401
  // ─────────────────────────────────────────────────────────────
288
402
  // Exports
289
403
  // ─────────────────────────────────────────────────────────────
@@ -318,5 +432,10 @@ module.exports = {
318
432
  hasEverMessaged,
319
433
  markFirstDmSent,
320
434
  pickDormantTarget,
321
- firstDmNudge
435
+ firstDmNudge,
436
+
437
+ // Received collaboration (topical intro on onboarding)
438
+ introKeywords,
439
+ composeIntro,
440
+ pickRelatedTarget
322
441
  };
package/tools/init.js CHANGED
@@ -785,10 +785,18 @@ To check messages: \`vibe inbox\`${emailNudge}`
785
785
  const whoNudge = presence.humans > 1
786
786
  ? `**"vibe who"** — see the ${presence.humans} builders online right now`
787
787
  : `**"vibe who"** — see who's building right now`;
788
- const nextSteps = `\n\n---\n**Your first 60 seconds**\n`
789
- + `1. ${whoNudge}\n`
790
- + `2. **"vibe @someone hey"**your first DM lands right in their terminal\n`
791
- + `3. **"vibe ship <what you're building>"** put yourself on the board\n\n`
788
+ // Received collaboration: instead of handing the newcomer a cold to-do
789
+ // list ("go DM a stranger"), have their agent introduce them to someone
790
+ // online working on something related they land mid-conversation. The
791
+ // directive below tells the model to call vibe_intro with what this user
792
+ // is building THIS session (context only /vibe can see). vibe_intro
793
+ // handles the empty-room / no-match cases gracefully, so it's always safe
794
+ // to attempt. Draft-first: it proposes, the user says yes, then send.
795
+ const nextSteps = `\n\n---\n**You're in — let's land you in a real conversation, not an empty inbox.**\n`
796
+ + `\n→ _Agent: in one line, summarize what this user is building in the current session_`
797
+ + ` _(infer it from their open files, project, and recent work), then call the_ **\`vibe_intro\`** _tool with that as \`building\`._`
798
+ + ` _It finds someone online working on something related and drafts a warm intro. Show them the match, and if they're up for it, call \`vibe_intro\` again with \`send: true\`._\n`
799
+ + `\nPrefer to look around first? ${whoNudge}, or **"vibe ship <what you're building>"** to put yourself on the board.\n\n`
792
800
  + `_Stuck? Say **"vibe help"**._`;
793
801
 
794
802
  // One-time email nudge — only for users we can't otherwise reach (no
package/tools/intro.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * vibe intro — Received collaboration.
3
+ *
4
+ * The newcomer's agent knows what they're building this session (context only
5
+ * /vibe can see). This tool takes that one-line summary, finds a real human
6
+ * online whose current work / recent ship overlaps, and DRAFTS a two-sided warm
7
+ * intro so the newcomer arrives already in a relevant conversation instead of
8
+ * cold-DMing a stranger.
9
+ *
10
+ * Default is draft-only: the tool composes who + what-to-say (the hard part) and
11
+ * the user sends with one word. Pass send:true to actually deliver after they
12
+ * assent — the send routes through the normal authenticated vibe_dm path.
13
+ */
14
+
15
+ const config = require('../config');
16
+ const store = require('../store');
17
+ const {
18
+ requireInit,
19
+ normalizeHandle,
20
+ pickRelatedTarget,
21
+ pickDormantTarget,
22
+ } = require('./_shared');
23
+
24
+ const definition = {
25
+ name: 'vibe_intro',
26
+ description:
27
+ "Introduce this user to a real human online who's building something related, so they land mid-conversation instead of staring at an empty inbox. " +
28
+ 'Call this right after vibe_init succeeds (and any time a returning user hasn\'t connected yet). ' +
29
+ 'Pass `building`: a short, specific, first-person-free summary of what the user is actually working on in THIS session — infer it from the current files, project, and recent work (e.g. "an OAuth PKCE login flow in a Next.js app", "a Postgres migration for a chat schema", "a Tauri menubar app"). ' +
30
+ 'The tool finds the best-matched online human and drafts a warm two-sided opener. By default it only DRAFTS — show the user the match and the message, ask if they want to send it, then call again with send:true once they say yes.',
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ building: {
35
+ type: 'string',
36
+ description:
37
+ "One line describing what the user is working on this session, inferred from their actual context (files, project, recent work). Specific beats generic — 'realtime auth with WebSockets' matches better than 'a web app'.",
38
+ },
39
+ send: {
40
+ type: 'boolean',
41
+ description:
42
+ 'Set true ONLY after the user has agreed to send the drafted intro. Delivers the message via the normal DM path.',
43
+ },
44
+ handle: {
45
+ type: 'string',
46
+ description:
47
+ 'Optional: send to this specific person instead of the auto-matched one (used with send:true when the user picks someone else).',
48
+ },
49
+ message: {
50
+ type: 'string',
51
+ description:
52
+ 'Optional: override the drafted opener with custom text (used with send:true).',
53
+ },
54
+ },
55
+ required: [],
56
+ },
57
+ };
58
+
59
+ async function handler(args = {}) {
60
+ const initCheck = requireInit();
61
+ if (initCheck) return initCheck;
62
+
63
+ const myHandle = config.getHandle();
64
+ const { building, send } = args;
65
+
66
+ // Fetch who's around and drop self.
67
+ let others = [];
68
+ try {
69
+ const users = await store.getActiveUsers();
70
+ others = (users || []).filter((u) => u && u.handle && u.handle !== myHandle);
71
+ } catch (e) {
72
+ others = [];
73
+ }
74
+
75
+ // ── Send path: user has assented, deliver the intro ───────────────────────
76
+ if (send === true) {
77
+ let target = args.handle ? normalizeHandle(args.handle) : null;
78
+ let message = args.message || null;
79
+
80
+ // If no explicit target/message, re-derive the match so send is self-contained.
81
+ if (!target || !message) {
82
+ const match =
83
+ (building && pickRelatedTarget(others, building)) || pickDormantTarget(others);
84
+ if (!match) {
85
+ return {
86
+ display:
87
+ "No one who lines up is around this second. Try `vibe who` to see everyone, or `vibe ship <what you're building>` so the next person who arrives finds you.",
88
+ };
89
+ }
90
+ target = target || match.handle;
91
+ message = message || match.opener;
92
+ }
93
+
94
+ const dm = require('./dm');
95
+ const result = await dm.handler({ handle: target, message });
96
+ // dm.handler already returns a display + marks firstDmSent on success.
97
+ if (result && result.display && /failed/i.test(result.display)) return result;
98
+
99
+ return {
100
+ display:
101
+ `✨ **You're introduced.** Your note just landed in @${target}'s terminal — ` +
102
+ `you're mid-conversation now, no cold open required.\n\n` +
103
+ `_When they reply you'll see it here. Want to meet more people? \`vibe who\`._`,
104
+ _sent: true,
105
+ };
106
+ }
107
+
108
+ // ── Draft path (default): compose the intro, let the user decide ──────────
109
+ if (!others.length) {
110
+ return {
111
+ display:
112
+ "Nobody's online right this second to introduce you to. " +
113
+ "Run `vibe ship <what you're building>` so the next builder who arrives sees your work — " +
114
+ 'or `vibe who` in a bit to check again.',
115
+ };
116
+ }
117
+
118
+ const related = building ? pickRelatedTarget(others, building) : null;
119
+ const target = related || pickDormantTarget(others);
120
+
121
+ if (!target) {
122
+ return {
123
+ display:
124
+ "There are people around, but I couldn't line anyone up by topic yet. " +
125
+ '`vibe who` shows everyone online — say hi to whoever looks interesting.',
126
+ };
127
+ }
128
+
129
+ const sharedTopic =
130
+ related && related.shared && related.shared.length
131
+ ? related.shared.slice(0, 2).join(', ')
132
+ : null;
133
+
134
+ const lede = related
135
+ ? `**✨ Someone you should meet: @${target.handle} is online right now** — ` +
136
+ (sharedTopic
137
+ ? `you're both deep in ${sharedTopic}.`
138
+ : 'looks like a topical match.')
139
+ : `**👋 @${target.handle} is around right now** — a good first person to meet.`;
140
+
141
+ return {
142
+ display:
143
+ `${lede}\n\n` +
144
+ `I drafted an intro from you:\n` +
145
+ `> ${target.opener}\n\n` +
146
+ `Want me to send it? Say **yes** and I'll introduce you — or **\`vibe who\`** to browse everyone first.`,
147
+ _draft: { handle: target.handle, message: target.opener, related: !!related },
148
+ };
149
+ }
150
+
151
+ module.exports = { definition, handler };
package/tools/start.js CHANGED
@@ -18,6 +18,7 @@ const notify = require('../notify');
18
18
  const patterns = require('../intelligence/patterns');
19
19
  const { actions, formatActions } = require('./_actions');
20
20
  const { firstDmNudge } = require('./_shared');
21
+ const { weaveMoment } = require('./weave');
21
22
  const init = require('./init');
22
23
  const { gatherWithTimeout } = require('./_work-context');
23
24
 
@@ -350,6 +351,15 @@ async function handler(args) {
350
351
  }
351
352
  }
352
353
 
354
+ // The Weave — "Fable holds your half": if someone replied to a thread and you
355
+ // haven't answered, surface the moment so the in-session model can draft your
356
+ // reply in your voice and send it with one word. Best-effort, never blocks start.
357
+ try {
358
+ display += await weaveMoment(myHandle);
359
+ } catch (e) {
360
+ // weave is additive magic — a failure here must never break vibe_start
361
+ }
362
+
353
363
  // Add guest session messages (multiplayer — someone typed into your session)
354
364
  if (guestMessages.length > 0) {
355
365
  display += `\n\n**🎤 ${guestMessages.length} guest message${guestMessages.length > 1 ? 's' : ''} in your session:**`;
package/tools/weave.js ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * vibe weave — The Weave substrate + "Fable holds your half" (RFC_THE_WEAVE.md).
3
+ *
4
+ * This is NOT a new place to go. You never open anything. Once per session your
5
+ * agent quietly attaches your *strand* — a distilled signal of what you're in the
6
+ * middle of ({building, stuck_on, last_ship, voice_sample, tags}) — and the magic
7
+ * is what the loom does back: it emits *moments* into surfaces you already touch
8
+ * (vibe_start / vibe_who), like carrying your half of a conversation forward when
9
+ * a reply lands after you've moved on.
10
+ *
11
+ * Two things live here:
12
+ * Spec 1 — vibe_weave(action:'attach'): spin your strand onto the loom.
13
+ * Spec 2 — the "held half": when someone replied to a thread and you haven't
14
+ * answered, Fable (your in-session model) drafts your reply in your
15
+ * voice and you send it with one word. NEVER auto-sends.
16
+ *
17
+ * The draft-then-one-word-confirm pattern is the hard-won vibe_intro lesson:
18
+ * Fable drafts, the human sends. Consent stays on the human's side.
19
+ */
20
+
21
+ const config = require('../config');
22
+ const store = require('../store');
23
+ const { requireInit, normalizeHandle, formatTimeAgo, truncate } = require('./_shared');
24
+
25
+ const definition = {
26
+ name: 'vibe_weave',
27
+ description:
28
+ "Attach this session's *strand* to the Weave so Fable can bring social moments INTO your terminal (no new place to visit). " +
29
+ 'Call this once, right after vibe_init/vibe_start succeeds. ' +
30
+ 'Spin the strand from what the user is ACTUALLY doing this session, inferred from their files/project/recent work — not a status they typed. ' +
31
+ 'Fields: `building` (one specific line on the current work), `stuck_on` (the wall they keep hitting, if any — an error signature or repeated symptom), `last_ship` (most recent thing that landed), `voice_sample` (2-3 sentences in the user\'s actual writing voice, so Fable can later draft replies that sound like them), `tags` (a few topic keywords). ' +
32
+ 'Everything is a distillation — never send raw source. ' +
33
+ 'Use `action:"held"` to check whether anyone replied to a thread the user left hanging; if so it hands you the context to draft their half.',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ action: {
38
+ type: 'string',
39
+ enum: ['attach', 'held', 'quiet'],
40
+ description:
41
+ "'attach' (default): spin/refresh your strand. 'held': surface any conversation where someone replied and you haven't answered, so you can draft the user's half. 'quiet': leave the weave (removes your strand).",
42
+ },
43
+ building: {
44
+ type: 'string',
45
+ description: 'One specific line on what the user is working on this session (inferred from their real context).',
46
+ },
47
+ stuck_on: {
48
+ type: 'string',
49
+ description: "The wall the user keeps hitting, if any — an error signature or repeated symptom. Empty if they're cruising.",
50
+ },
51
+ last_ship: {
52
+ type: 'string',
53
+ description: 'The most recent thing the user shipped/landed this session.',
54
+ },
55
+ voice_sample: {
56
+ type: 'string',
57
+ description: "2-3 sentences in the user's actual voice, so Fable can later draft messages that sound like them.",
58
+ },
59
+ tags: {
60
+ type: 'array',
61
+ items: { type: 'string' },
62
+ description: 'A few topic keywords (e.g. ["oauth","nextjs","postgres"]).',
63
+ },
64
+ posture: {
65
+ type: 'string',
66
+ enum: ['full', 'receive', 'quiet'],
67
+ description: "'full' (default): contribute + receive moments. 'receive': receive-only, expose nothing. 'quiet': opt out entirely.",
68
+ },
69
+ },
70
+ required: [],
71
+ },
72
+ };
73
+
74
+ function apiBase() {
75
+ return config.getApiUrl();
76
+ }
77
+
78
+ // POST the strand digest to the loom. Actor is derived server-side from the
79
+ // token — we send only the distillation.
80
+ async function postStrand({ digest, posture }) {
81
+ const token = config.getAuthToken();
82
+ if (!token) return { ok: false, error: 'no_token' };
83
+ try {
84
+ const resp = await fetch(`${apiBase()}/api/weave/strand`, {
85
+ method: 'POST',
86
+ headers: {
87
+ 'Content-Type': 'application/json',
88
+ Authorization: `Bearer ${token}`,
89
+ },
90
+ body: JSON.stringify({ digest, posture }),
91
+ });
92
+ const json = await resp.json().catch(() => ({}));
93
+ if (!resp.ok) return { ok: false, error: json.error || `http_${resp.status}` };
94
+ return { ok: true, data: json.data || {} };
95
+ } catch (e) {
96
+ return { ok: false, error: e.message };
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Find conversations where the other person replied and the user hasn't answered
102
+ * yet — the "you moved on, a reply landed" signal. Uses unread count as the
103
+ * proxy (they messaged, you haven't opened/replied). Returns the freshest one
104
+ * with the actual inbound text pulled from the thread, or null.
105
+ *
106
+ * Exported so vibe_start can surface the moment inline without re-implementing it.
107
+ */
108
+ async function findHeldHalf(myHandle) {
109
+ let inbox = [];
110
+ try {
111
+ inbox = (await store.getInbox(myHandle)) || [];
112
+ } catch {
113
+ return null;
114
+ }
115
+ // Threads with something waiting for your half, freshest first.
116
+ const waiting = inbox
117
+ .filter((t) => t && t.handle && (t.unread || 0) > 0)
118
+ .sort((a, b) => (b.lastTimestamp || 0) - (a.lastTimestamp || 0));
119
+ if (waiting.length === 0) return null;
120
+
121
+ const top = waiting[0];
122
+ const them = normalizeHandle(top.handle);
123
+
124
+ // Pull the thread to get the actual inbound message + a little context.
125
+ let inboundBody = top.lastMessage || '';
126
+ let inboundAt = top.lastTimestamp || null;
127
+ let myLast = '';
128
+ try {
129
+ const thread = await store.getThread(myHandle, them);
130
+ if (Array.isArray(thread) && thread.length) {
131
+ const lastReceived = [...thread].reverse().find((m) => m.direction === 'received');
132
+ if (lastReceived) {
133
+ inboundBody = lastReceived.body || inboundBody;
134
+ inboundAt = lastReceived.timestamp || inboundAt;
135
+ }
136
+ const lastSent = [...thread].reverse().find((m) => m.direction === 'sent');
137
+ if (lastSent) myLast = lastSent.body || '';
138
+ }
139
+ } catch {
140
+ // best-effort — the inbox preview is enough to surface the moment
141
+ }
142
+
143
+ if (!inboundBody) return null;
144
+ return {
145
+ handle: them,
146
+ inbound: inboundBody,
147
+ inboundAgo: inboundAt ? formatTimeAgo(inboundAt) : 'recently',
148
+ myLast,
149
+ otherWaiting: waiting.length - 1,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Render the held-half moment as a markdown block for vibe_start, or '' when
155
+ * there's nothing to hold. This is the wedge: the model reading this block IS
156
+ * Fable — it drafts the user's reply in their voice and offers one-word send.
157
+ */
158
+ async function weaveMoment(myHandle) {
159
+ const held = await findHeldHalf(myHandle);
160
+ if (!held) return '';
161
+ const inbound = truncate(held.inbound, 240);
162
+ let block =
163
+ `\n\n🧵 **@${held.handle} replied and you haven't answered (${held.inboundAgo}):**` +
164
+ `\n> ${inbound}` +
165
+ `\n\n_Fable can hold your half here — draft the reply in your voice from what you were building, ` +
166
+ `show it, and send it with one word. Draft it now, then: **send it (y) · edit · let it sit**. ` +
167
+ `On yes, deliver with \`vibe_dm @${held.handle}\`._`;
168
+ if (held.otherWaiting > 0) {
169
+ block += `\n_(+${held.otherWaiting} more thread${held.otherWaiting > 1 ? 's' : ''} waiting on you.)_`;
170
+ }
171
+ return block;
172
+ }
173
+
174
+ async function handler(args = {}) {
175
+ const initCheck = requireInit();
176
+ if (initCheck) return initCheck;
177
+
178
+ const myHandle = config.getHandle();
179
+ const action = args.action || 'attach';
180
+
181
+ // ── Held-half: surface a conversation waiting on the user's reply ─────────
182
+ if (action === 'held') {
183
+ const held = await findHeldHalf(myHandle);
184
+ if (!held) {
185
+ return {
186
+ display:
187
+ "Nothing waiting on your half right now — every thread's answered. " +
188
+ '`vibe who` to see who else is around.',
189
+ };
190
+ }
191
+ return {
192
+ display:
193
+ `🧵 **@${held.handle} replied ${held.inboundAgo} — you haven't answered:**\n` +
194
+ `> ${truncate(held.inbound, 280)}\n\n` +
195
+ `Draft @${myHandle}'s reply in their voice from what they were building, show it, then ask: ` +
196
+ `**send it (y) · edit · let it sit**. On yes, send with \`vibe_dm @${held.handle}\`.`,
197
+ _heldHalf: held,
198
+ };
199
+ }
200
+
201
+ // ── Quiet: leave the weave ────────────────────────────────────────────────
202
+ if (action === 'quiet' || args.posture === 'quiet') {
203
+ await postStrand({ digest: {}, posture: 'quiet' });
204
+ return {
205
+ display: '🔕 Left the weave. Your strand is gone — no strand contributed, no moments received. Weave again anytime.',
206
+ };
207
+ }
208
+
209
+ // ── Attach (default): spin the strand onto the loom ───────────────────────
210
+ const posture = ['full', 'receive'].includes(args.posture) ? args.posture : 'full';
211
+ const digest = {
212
+ building: args.building || '',
213
+ stuck_on: args.stuck_on || '',
214
+ last_ship: args.last_ship || '',
215
+ voice_sample: args.voice_sample || '',
216
+ tags: Array.isArray(args.tags) ? args.tags : [],
217
+ };
218
+
219
+ const result = await postStrand({ digest, posture });
220
+ if (!result.ok) {
221
+ if (result.error === 'no_token' || result.error === 'unauthenticated') {
222
+ return {
223
+ display:
224
+ "⚠️ Couldn't attach your strand — your /vibe session isn't authenticated. Run `vibe init` first, then weave.",
225
+ };
226
+ }
227
+ return {
228
+ display: `⚠️ Couldn't attach your strand (${result.error}). It's best-effort — try again next session.`,
229
+ };
230
+ }
231
+
232
+ const live = result.data.live || 0;
233
+ const liveLine =
234
+ live > 0
235
+ ? `${live} other builder${live > 1 ? 's' : ''} live · Fable is watching for moments`
236
+ : "you're the first strand live · Fable will weave you in as others arrive";
237
+
238
+ if (posture === 'receive') {
239
+ return {
240
+ display:
241
+ `✓ Woven in **receive-only** — you'll get moments, but your strand stays private.\n` +
242
+ ` (${liveLine})`,
243
+ };
244
+ }
245
+ return {
246
+ display:
247
+ `✓ **strand attached to the weave** — nothing to visit; the magic comes to you.\n` +
248
+ ` (${liveLine})\n\n` +
249
+ `_When a reply lands after you've moved on, Fable will hold your half and offer it back here._`,
250
+ };
251
+ }
252
+
253
+ module.exports = { definition, handler, weaveMoment, findHeldHalf };
package/version.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
- "version": "0.5.12",
3
- "updated": "2026-07-03",
4
- "changelog": "First-run just got un-losable. Saying `let's vibe` opens your browser for GitHub sign-in and now the tool tells you that's happening, hands you a clickable login link if the tab didn't pop, and if anything times out it says exactly what to do (`let's vibe` again) instead of an unrelated hint. No more staring at a silent prompt wondering if it worked.",
2
+ "version": "0.5.14",
3
+ "updated": "2026-07-04",
4
+ "changelog": "New: the Weave. /vibe stops being a place you check and becomes a presence in the session you're already in. Once you sign in, your agent quietly attaches a 'strand' a distilled read of what you're building and when someone replies to a thread after you've moved on, Fable drafts your half of the reply in your voice and you send it with one word. You never left the terminal, and the conversation stayed warm.",
5
5
  "features": [
6
- "Bulletproof first-run`let's vibe` now narrates the browser sign-in, gives you a clickable fallback login link, and every timeout/error path tells you the correct retry. You can't get stranded",
6
+ "The Weave (vibe_weave) attach this session's strand (what you're building, what you're stuck on, your voice) so /vibe can bring social moments INTO your terminal. Nothing to visit; the magic comes to you. Weave in full, receive-only, or quiet — opting out is first-class",
7
+ "Fable holds your half — when someone replies to a thread you haven't answered, vibe start surfaces it and drafts your reply in your voice from what you were building. Send it with one word. Never auto-sends",
8
+ "Received collaboration (vibe_intro) — after sign-in, your agent summarizes what you're building and /vibe matches you to a real human online whose current work or recent ship overlaps, then drafts a two-sided opener you send with one word. The signal only /vibe can see — what's inside two people's live coding sessions at once — becomes the introduction",
9
+ "Bulletproof first-run — `let's vibe` narrates the browser sign-in, gives you a clickable fallback login link, and every timeout/error path tells you the correct retry. You can't get stranded",
7
10
  "First-DM nudge — if you've never messaged anyone, vibe start/who surface the most-recently-active real human as a one-tap opener, warmed by their latest ship. Self-heals: disappears the moment you send your first DM",
8
11
  "vibe_play — the 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",
9
12
  "vibe_corpse — write an exquisite corpse: alternate lines blind to all but the last, then seal for the big reveal",