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 +7 -0
- package/package.json +1 -1
- package/tools/_shared.js +120 -1
- package/tools/init.js +12 -4
- package/tools/intro.js +151 -0
- package/tools/start.js +10 -0
- package/tools/weave.js +253 -0
- package/version.json +7 -4
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.
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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.
|
|
3
|
-
"updated": "2026-07-
|
|
4
|
-
"changelog": "
|
|
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
|
-
"
|
|
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",
|