slashvibe-mcp 0.8.18 → 0.8.20
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/capabilities.js +133 -0
- package/index.js +53 -15
- package/notification-emitter.js +2 -2
- package/package.json +18 -8
- package/personal-mind.js +160 -0
- package/scripts/reflect-provider-canary.mjs +35 -0
- package/scripts/verb-canary.mjs +102 -0
- package/store/api.js +13 -3
- package/tool-privacy.js +23 -0
- package/tools/call.js +69 -0
- package/tools/capabilities.js +28 -0
- package/tools/help.js +1 -1
- package/tools/inbox.js +15 -6
- package/tools/init.js +151 -186
- package/tools/mind.js +93 -0
- package/tools/reflect.js +133 -0
- package/tools/remember.js +32 -0
- package/tools/reply.js +41 -28
- package/tools/start.js +6 -5
- package/version.json +8 -8
package/capabilities.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The truthful local capability manifest — the runtime kernel's one answer to
|
|
3
|
+
* "what can this install actually do, right now, for this person?"
|
|
4
|
+
*
|
|
5
|
+
* Canon (platform PR #333 / epic #329): the runtime COORDINATES capabilities;
|
|
6
|
+
* it does not ingest or centralize them. Discovery must not read private data
|
|
7
|
+
* or silently install anything. Every capability reports exactly one of:
|
|
8
|
+
*
|
|
9
|
+
* granted — an explicit, revocable grant exists and the capability works
|
|
10
|
+
* available — the provider is present locally; no grant yet (or none needed)
|
|
11
|
+
* off — the person turned it off (env flag); presence not probed
|
|
12
|
+
* unavailable — no provider detected; said plainly, never guessed around
|
|
13
|
+
*
|
|
14
|
+
* Detection is EXISTENCE-ONLY: fs.existsSync / lstat on well-known paths and
|
|
15
|
+
* PATH entries. No file contents are read here except the Mind bearer's
|
|
16
|
+
* permission bits (personal-mind.js, which reads its own 0600 token — that IS
|
|
17
|
+
* the grant). No subprocess runs. No network.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const os = require('node:os');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
const personalMind = require('./personal-mind');
|
|
24
|
+
const authStore = require('./auth-store');
|
|
25
|
+
|
|
26
|
+
const OFF_VALUES = new Set(['off', '0', 'false', 'no']);
|
|
27
|
+
|
|
28
|
+
function envOff(name) {
|
|
29
|
+
return OFF_VALUES.has(String(process.env[name] || '').toLowerCase());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function onPath(binary) {
|
|
33
|
+
const entries = String(process.env.PATH || '').split(path.delimiter);
|
|
34
|
+
return entries.some((dir) => {
|
|
35
|
+
try {
|
|
36
|
+
return dir && fs.existsSync(path.join(dir, binary));
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function vibeCheckPresent() {
|
|
44
|
+
// Stan's collector: the db location is its documented contract. Existence
|
|
45
|
+
// only — the database is never opened by discovery.
|
|
46
|
+
return (
|
|
47
|
+
fs.existsSync(path.join(os.homedir(), '.vibe-check', 'vibe_check.db')) ||
|
|
48
|
+
onPath('vibe-check')
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function executableFile(p) {
|
|
53
|
+
// Available must mean RUNNABLE: a regular file with an execute bit. A
|
|
54
|
+
// directory or a mode-0600 blob is not a provider (review P1).
|
|
55
|
+
try {
|
|
56
|
+
const st = fs.statSync(p);
|
|
57
|
+
return st.isFile() && (st.mode & 0o111) !== 0;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function vibeStatsProvider() {
|
|
64
|
+
// Provider contract: an executable named `vibestats`, or an explicit path
|
|
65
|
+
// in VIBE_STATS_CLI. Executability is checked; nothing is executed during
|
|
66
|
+
// discovery.
|
|
67
|
+
const explicit = process.env.VIBE_STATS_CLI;
|
|
68
|
+
if (explicit) return executableFile(explicit) ? explicit : null;
|
|
69
|
+
const entries = String(process.env.PATH || '').split(path.delimiter);
|
|
70
|
+
for (const dir of entries) {
|
|
71
|
+
if (dir && executableFile(path.join(dir, 'vibestats'))) return path.join(dir, 'vibestats');
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The manifest. Pure with respect to the wire: nothing here talks to the
|
|
78
|
+
* platform or any provider — states describe THIS machine, honestly.
|
|
79
|
+
*/
|
|
80
|
+
function manifest() {
|
|
81
|
+
// The CREDENTIAL answers, in memory — never the filesystem, and never a
|
|
82
|
+
// remembered username without a credential (review P1: a leftover name is
|
|
83
|
+
// not a principal). authStore hydrates once at server boot.
|
|
84
|
+
const message = authStore.isAuthenticated()
|
|
85
|
+
? { state: 'granted', why: 'signed in as one /vibe principal' }
|
|
86
|
+
: { state: 'available', why: 'runtime installed; sign in with vibe start' };
|
|
87
|
+
|
|
88
|
+
let remember;
|
|
89
|
+
if (envOff('VIBE_REMEMBER')) {
|
|
90
|
+
remember = { state: 'off', why: 'VIBE_REMEMBER is off; providers not probed' };
|
|
91
|
+
} else if (personalMind.isAvailable()) {
|
|
92
|
+
remember = { state: 'granted', why: 'Personal Mind activation present (revocable: remove the activation file)' };
|
|
93
|
+
} else if (vibeCheckPresent()) {
|
|
94
|
+
remember = { state: 'available', why: 'VibeCheck detected locally; no Mind grant activated' };
|
|
95
|
+
} else {
|
|
96
|
+
remember = { state: 'unavailable', why: 'no local memory provider detected' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let reflect;
|
|
100
|
+
if (envOff('VIBE_REFLECT')) {
|
|
101
|
+
reflect = { state: 'off', why: 'VIBE_REFLECT is off; providers not probed' };
|
|
102
|
+
} else {
|
|
103
|
+
const provider = vibeStatsProvider();
|
|
104
|
+
reflect = provider
|
|
105
|
+
? { state: 'available', why: `VibeStats provider present (${path.basename(provider)})` }
|
|
106
|
+
: { state: 'unavailable', why: 'no local VibeStats provider detected' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const call = envOff('VIBE_CALL')
|
|
110
|
+
? { state: 'off', why: 'VIBE_CALL is off' }
|
|
111
|
+
: { state: 'available', why: 'explicit live-room handoff via calljimmy.ai; nothing joins without a person' };
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
message: { provider: '/vibe', ...message },
|
|
115
|
+
remember: { provider: 'vibecheck', ...remember },
|
|
116
|
+
reflect: { provider: 'vibestats', ...reflect },
|
|
117
|
+
call: { provider: 'vibeconf', ...call },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const STATE_GLYPH = { granted: '●', available: '○', off: '·', unavailable: '—' };
|
|
122
|
+
|
|
123
|
+
function renderLine(verb, cap) {
|
|
124
|
+
return `${STATE_GLYPH[cap.state] || '?'} ${verb} — ${cap.state} · ${cap.why}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function render(m = manifest()) {
|
|
128
|
+
return ['remember', 'reflect', 'message', 'call']
|
|
129
|
+
.map((verb) => renderLine(verb, m[verb]))
|
|
130
|
+
.join('\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { manifest, render, _detect: { envOff, onPath, vibeCheckPresent, vibeStatsProvider } };
|
package/index.js
CHANGED
|
@@ -14,6 +14,7 @@ const presence = require('./presence');
|
|
|
14
14
|
const config = require('./config');
|
|
15
15
|
const store = require('./store');
|
|
16
16
|
const prompts = require('./prompts');
|
|
17
|
+
const toolPrivacy = require('./tool-privacy');
|
|
17
18
|
const NotificationEmitter = require('./notification-emitter');
|
|
18
19
|
const authStore = require('./auth-store');
|
|
19
20
|
const actorSession = require('./actor-session');
|
|
@@ -47,7 +48,11 @@ const SERVER_CAPABILITIES = {
|
|
|
47
48
|
};
|
|
48
49
|
|
|
49
50
|
// Tools that shouldn't show presence footer (would be redundant/noisy)
|
|
50
|
-
|
|
51
|
+
// The local-only verbs make NO platform side effects (review P1): the
|
|
52
|
+
// ambient footer performs platform requests, so remember/reflect/call and
|
|
53
|
+
// the manifest never carry it.
|
|
54
|
+
const SKIP_FOOTER_TOOLS = ['vibe_init', 'vibe_doctor', 'vibe_test', 'vibe_update',
|
|
55
|
+
'vibe_capabilities', 'vibe_remember', 'vibe_reflect', 'vibe_call'];
|
|
51
56
|
|
|
52
57
|
// Progressive disclosure: only these tools are visible before authentication
|
|
53
58
|
// After auth, the full toolset is revealed via tools/list_changed notification
|
|
@@ -59,12 +64,19 @@ const PRE_AUTH_TOOLS = new Set([
|
|
|
59
64
|
'vibe_dm', // Shown so users can try — triggers auth gate
|
|
60
65
|
'vibe_inbox', // Shown so users can try — triggers auth gate
|
|
61
66
|
'vibe_status', // Shown so users can try — triggers auth gate
|
|
62
|
-
'vibe_help'
|
|
67
|
+
'vibe_help', // Always accessible
|
|
68
|
+
// The truthful capability manifest is exactly what a fresh install should
|
|
69
|
+
// be able to ask: it reads no private data and reports message as
|
|
70
|
+
// "available · sign in with vibe start" until a principal exists.
|
|
71
|
+
'vibe_capabilities'
|
|
63
72
|
]);
|
|
64
73
|
|
|
65
74
|
// Tools that genuinely work without authentication
|
|
66
75
|
const NO_AUTH_REQUIRED = new Set([
|
|
67
|
-
'vibe_start', 'vibe_init', 'vibe_token', 'vibe_who', 'vibe_help'
|
|
76
|
+
'vibe_start', 'vibe_init', 'vibe_token', 'vibe_who', 'vibe_help',
|
|
77
|
+
// The manifest is local truth; signed out it must ANSWER (message:
|
|
78
|
+
// available · sign in), never start an auth flow (review P1).
|
|
79
|
+
'vibe_capabilities'
|
|
68
80
|
]);
|
|
69
81
|
|
|
70
82
|
// "Is this session signed in" — the credential answers, not the filesystem.
|
|
@@ -305,6 +317,15 @@ const kernelTools = {
|
|
|
305
317
|
// Utility
|
|
306
318
|
vibe_help: require('./tools/help'),
|
|
307
319
|
vibe_bye: require('./tools/bye'),
|
|
320
|
+
|
|
321
|
+
// ── The four verbs (canon PR #333 / epic #329) ─────────────────────────
|
|
322
|
+
// remember · reflect · message · call. Messaging IS vibe_dm/inbox/reply
|
|
323
|
+
// above; these three complete the verb set, and the manifest tells the
|
|
324
|
+
// truth about every one of them. Install once is not consent once.
|
|
325
|
+
vibe_capabilities: require('./tools/capabilities'),
|
|
326
|
+
vibe_remember: require('./tools/remember'),
|
|
327
|
+
vibe_reflect: require('./tools/reflect'),
|
|
328
|
+
vibe_call: require('./tools/call'),
|
|
308
329
|
};
|
|
309
330
|
|
|
310
331
|
// ─── Extras ──────────────────────────────────────────────────────────────
|
|
@@ -352,7 +373,13 @@ const adminTools = process.env.VIBE_ADMIN === 'true' ? {
|
|
|
352
373
|
vibe_patterns: require('./tools/patterns'),
|
|
353
374
|
} : {};
|
|
354
375
|
|
|
355
|
-
//
|
|
376
|
+
// The Personal Mind seam now answers through the verb: vibe_remember reports
|
|
377
|
+
// the capability state honestly without a grant and consults the private edge
|
|
378
|
+
// with one. The vibe_mind name is retired from tools/list; its engine
|
|
379
|
+
// (tools/mind.js) is the granted path inside vibe_remember.
|
|
380
|
+
|
|
381
|
+
// Combine tools — kernel always (the four verbs report their own truthful
|
|
382
|
+
// states); extras only when opted in.
|
|
356
383
|
const tools = { ...kernelTools, ...(EXTRAS_ENABLED ? extraTools : {}), ...adminTools };
|
|
357
384
|
|
|
358
385
|
/**
|
|
@@ -559,16 +586,21 @@ class VibeMCPServer {
|
|
|
559
586
|
global.vibeNotifier?.emitImmediate();
|
|
560
587
|
}
|
|
561
588
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
589
|
+
// Sign-in is non-blocking now: init returns auth_required and the
|
|
590
|
+
// person finishes in a browser. Only claim "You're in!" when the
|
|
591
|
+
// credential actually exists (review P1) — otherwise relay init's
|
|
592
|
+
// honest state and its structured mirror.
|
|
593
|
+
const signedIn = isAuthed();
|
|
594
|
+
const gateResult = {
|
|
595
|
+
content: [{
|
|
596
|
+
type: 'text',
|
|
597
|
+
text: signedIn
|
|
598
|
+
? `> _You tried to **${actionName}** — signing you in first._\n\n${initDisplay}\n\n---\n💡 **You're in!** Try your \`${actionName}\` command again.`
|
|
599
|
+
: `> _You tried to **${actionName}** — sign in first._\n\n${initDisplay}\n\n---\n_Then say \`${actionName}\` again._`
|
|
600
|
+
}]
|
|
571
601
|
};
|
|
602
|
+
if (initResult.structured) gateResult.structuredContent = initResult.structured;
|
|
603
|
+
return { jsonrpc: '2.0', id, result: gateResult };
|
|
572
604
|
} catch (e) {
|
|
573
605
|
return {
|
|
574
606
|
jsonrpc: '2.0',
|
|
@@ -586,7 +618,13 @@ class VibeMCPServer {
|
|
|
586
618
|
try {
|
|
587
619
|
// Log prompt pattern (if _prompt passed) or infer from args
|
|
588
620
|
const args = params.arguments || {};
|
|
589
|
-
|
|
621
|
+
// Personal Mind inputs are unsent thought, not product analytics.
|
|
622
|
+
// Do not retain even a derived prompt or recipient for this tool.
|
|
623
|
+
const inferredPrompt = toolPrivacy.retainedPrompt(
|
|
624
|
+
params.name,
|
|
625
|
+
args,
|
|
626
|
+
inferPromptFromArgs,
|
|
627
|
+
);
|
|
590
628
|
if (inferredPrompt) {
|
|
591
629
|
prompts.log(inferredPrompt, {
|
|
592
630
|
tool: params.name,
|
|
@@ -622,7 +660,7 @@ class VibeMCPServer {
|
|
|
622
660
|
// State-changing tools bust the cache first so the footer reflects
|
|
623
661
|
// what they just did (e.g. inbox read clears the unread badge).
|
|
624
662
|
let footer = '';
|
|
625
|
-
if (
|
|
663
|
+
if (toolPrivacy.shouldAppendAmbientFooter(params.name, SKIP_FOOTER_TOOLS)) {
|
|
626
664
|
if (AMBIENT_CACHE_BUSTERS.has(params.name)) bustAmbientCache();
|
|
627
665
|
footer = await resolveFooter(result, getPresenceFooter);
|
|
628
666
|
}
|
package/notification-emitter.js
CHANGED
|
@@ -30,7 +30,7 @@ class NotificationEmitter {
|
|
|
30
30
|
this.debounceTimers[reason] = setTimeout(() => {
|
|
31
31
|
try {
|
|
32
32
|
this.server.notification({
|
|
33
|
-
method: "notifications/list_changed"
|
|
33
|
+
method: "notifications/tools/list_changed"
|
|
34
34
|
});
|
|
35
35
|
delete this.debounceTimers[reason];
|
|
36
36
|
} catch (e) {
|
|
@@ -47,7 +47,7 @@ class NotificationEmitter {
|
|
|
47
47
|
emitImmediate() {
|
|
48
48
|
try {
|
|
49
49
|
this.server.notification({
|
|
50
|
-
method: "notifications/list_changed"
|
|
50
|
+
method: "notifications/tools/list_changed"
|
|
51
51
|
});
|
|
52
52
|
} catch (e) {
|
|
53
53
|
// Silent fail
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slashvibe-mcp",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.20",
|
|
4
4
|
"mcpName": "io.github.vibecodinginc/vibe",
|
|
5
|
-
"description": "Presence + messaging for terminal coding agents (Claude Code, Codex, Cursor)
|
|
5
|
+
"description": "Presence + messaging for terminal coding agents (Claude Code, Codex, Cursor) \u2014 the /vibe kernel",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"slashvibe-mcp": "cli.js",
|
|
@@ -48,15 +48,16 @@
|
|
|
48
48
|
},
|
|
49
49
|
"files": [
|
|
50
50
|
"README.md",
|
|
51
|
-
"api-auth.js",
|
|
52
51
|
"actor-session.js",
|
|
52
|
+
"ambient-escapes.js",
|
|
53
|
+
"api-auth.js",
|
|
53
54
|
"auth-store.js",
|
|
54
55
|
"auto-update.js",
|
|
56
|
+
"capabilities.js",
|
|
55
57
|
"cli.js",
|
|
56
58
|
"config.js",
|
|
57
59
|
"crypto.js",
|
|
58
60
|
"discord.js",
|
|
59
|
-
"ambient-escapes.js",
|
|
60
61
|
"footer.js",
|
|
61
62
|
"games/chess.js",
|
|
62
63
|
"hook-cli.js",
|
|
@@ -70,20 +71,23 @@
|
|
|
70
71
|
"memory.js",
|
|
71
72
|
"notification-emitter.js",
|
|
72
73
|
"oauth-callback.js",
|
|
74
|
+
"personal-mind.js",
|
|
73
75
|
"presence.js",
|
|
74
76
|
"prompts.js",
|
|
75
77
|
"protocol/handle.js",
|
|
76
78
|
"protocol/index.js",
|
|
77
79
|
"resources/presence-board.js",
|
|
78
80
|
"resources/vibe-tokens.json",
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"session-start-hook.cjs",
|
|
81
|
+
"scripts/reflect-provider-canary.mjs",
|
|
82
|
+
"scripts/verb-canary.mjs",
|
|
82
83
|
"session-start-hook-settings.js",
|
|
84
|
+
"session-start-hook.cjs",
|
|
85
|
+
"setup.js",
|
|
83
86
|
"store/api.js",
|
|
84
87
|
"store/index.js",
|
|
85
88
|
"store/local.js",
|
|
86
89
|
"store/profiles.js",
|
|
90
|
+
"tool-privacy.js",
|
|
87
91
|
"tools/_actions.js",
|
|
88
92
|
"tools/_discovery.js",
|
|
89
93
|
"tools/_shared.js",
|
|
@@ -91,6 +95,8 @@
|
|
|
91
95
|
"tools/admin-inbox.js",
|
|
92
96
|
"tools/artifact-view.js",
|
|
93
97
|
"tools/bye.js",
|
|
98
|
+
"tools/call.js",
|
|
99
|
+
"tools/capabilities.js",
|
|
94
100
|
"tools/corpse.js",
|
|
95
101
|
"tools/dm.js",
|
|
96
102
|
"tools/doctor.js",
|
|
@@ -103,9 +109,12 @@
|
|
|
103
109
|
"tools/inbox.js",
|
|
104
110
|
"tools/init.js",
|
|
105
111
|
"tools/intro.js",
|
|
112
|
+
"tools/mind.js",
|
|
106
113
|
"tools/patterns.js",
|
|
107
114
|
"tools/play.js",
|
|
108
115
|
"tools/poem.js",
|
|
116
|
+
"tools/reflect.js",
|
|
117
|
+
"tools/remember.js",
|
|
109
118
|
"tools/reply.js",
|
|
110
119
|
"tools/ship.js",
|
|
111
120
|
"tools/start.js",
|
|
@@ -116,6 +125,7 @@
|
|
|
116
125
|
"tools/update.js",
|
|
117
126
|
"tools/weave.js",
|
|
118
127
|
"tools/who.js",
|
|
119
|
-
"version.json"
|
|
128
|
+
"version.json",
|
|
129
|
+
"vibe-home.js"
|
|
120
130
|
]
|
|
121
131
|
}
|
package/personal-mind.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private Personal Mind transport for terminal hosts.
|
|
3
|
+
*
|
|
4
|
+
* This module is deliberately unable to call Platform. It accepts only the
|
|
5
|
+
* unsent active draft and up to eight recent visible messages, calls one fixed
|
|
6
|
+
* Tailnet origin, and writes nothing. The bearer stays in a regular 0600 file
|
|
7
|
+
* under the current identity directory.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { vibeHome } = require('./vibe-home');
|
|
13
|
+
|
|
14
|
+
const MIND_ORIGIN = 'http://100.121.205.111:7788';
|
|
15
|
+
const MAX_HANDLE_BYTES = 64;
|
|
16
|
+
const MAX_DRAFT_BYTES = 4_000;
|
|
17
|
+
const MAX_CONTEXT_BYTES = 2_000;
|
|
18
|
+
const MAX_RECENT_MESSAGES = 8;
|
|
19
|
+
const MAX_RESPONSE_BYTES = 256 * 1_024;
|
|
20
|
+
const PRIME_TIMEOUT_MS = 2_500;
|
|
21
|
+
|
|
22
|
+
const UNAVAILABLE = Symbol('unavailable');
|
|
23
|
+
|
|
24
|
+
function tokenPath() {
|
|
25
|
+
return path.join(vibeHome(), 'mind', 'runtime-token');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readPrivateToken(file = tokenPath()) {
|
|
29
|
+
try {
|
|
30
|
+
const stat = fs.lstatSync(file);
|
|
31
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) return null;
|
|
32
|
+
const token = fs.readFileSync(file, 'utf8').trim();
|
|
33
|
+
const size = Buffer.byteLength(token, 'utf8');
|
|
34
|
+
if (size < 24 || size > 512 || /\s/.test(token)) return null;
|
|
35
|
+
return token;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isAvailable() {
|
|
42
|
+
return readPrivateToken() !== null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validHandle(handle) {
|
|
46
|
+
return typeof handle === 'string'
|
|
47
|
+
&& Buffer.byteLength(handle, 'utf8') > 0
|
|
48
|
+
&& Buffer.byteLength(handle, 'utf8') <= MAX_HANDLE_BYTES
|
|
49
|
+
&& /^@?[a-zA-Z0-9_-]+$/.test(handle);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function utf8Prefix(value, limit) {
|
|
53
|
+
const text = String(value || '');
|
|
54
|
+
if (Buffer.byteLength(text, 'utf8') <= limit) return text;
|
|
55
|
+
let out = '';
|
|
56
|
+
for (const char of text) {
|
|
57
|
+
if (Buffer.byteLength(out + char, 'utf8') > limit) break;
|
|
58
|
+
out += char;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function recentContext(messages) {
|
|
64
|
+
if (!Array.isArray(messages)) return '';
|
|
65
|
+
const visible = messages.slice(-MAX_RECENT_MESSAGES).map((message) => {
|
|
66
|
+
const from = String(message?.from || '').replace(/[\r\n]/g, ' ').slice(0, 64);
|
|
67
|
+
const text = String(message?.text || '').replace(/\r\n?/g, '\n');
|
|
68
|
+
return `${from || 'unknown'}: ${text}`;
|
|
69
|
+
}).join('\n');
|
|
70
|
+
return utf8Prefix(visible, MAX_CONTEXT_BYTES);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function boundedJSON(response) {
|
|
74
|
+
const declared = Number(response.headers?.get?.('content-length') || 0);
|
|
75
|
+
if (declared > MAX_RESPONSE_BYTES) return null;
|
|
76
|
+
if (!response.body?.getReader) {
|
|
77
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
78
|
+
if (bytes.length > MAX_RESPONSE_BYTES) return null;
|
|
79
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
80
|
+
}
|
|
81
|
+
const reader = response.body.getReader();
|
|
82
|
+
const chunks = [];
|
|
83
|
+
let size = 0;
|
|
84
|
+
while (true) {
|
|
85
|
+
const { done, value } = await reader.read();
|
|
86
|
+
if (done) break;
|
|
87
|
+
size += value.byteLength;
|
|
88
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
89
|
+
await reader.cancel();
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
chunks.push(Buffer.from(value));
|
|
93
|
+
}
|
|
94
|
+
return JSON.parse(Buffer.concat(chunks, size).toString('utf8'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function post(route, payload, token, timeoutMs, fetchImpl = global.fetch) {
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
100
|
+
try {
|
|
101
|
+
const response = await fetchImpl(`${MIND_ORIGIN}${route}`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
redirect: 'error',
|
|
104
|
+
signal: controller.signal,
|
|
105
|
+
headers: {
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
Authorization: `Bearer ${token}`,
|
|
108
|
+
},
|
|
109
|
+
body: JSON.stringify(payload),
|
|
110
|
+
});
|
|
111
|
+
if (response.status === 401 || response.status === 403) return UNAVAILABLE;
|
|
112
|
+
if (!response.ok) return null;
|
|
113
|
+
return await boundedJSON(response);
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function ask({ handle, draft, recentMessages = [] }, fetchImpl = global.fetch) {
|
|
122
|
+
const normalizedHandle = String(handle || '').replace(/^@/, '').trim();
|
|
123
|
+
const activeDraft = String(draft || '');
|
|
124
|
+
if (!validHandle(normalizedHandle) || !activeDraft.trim()) return { error: 'invalid_input' };
|
|
125
|
+
if (Buffer.byteLength(activeDraft, 'utf8') > MAX_DRAFT_BYTES) {
|
|
126
|
+
return { error: 'draft_too_large' };
|
|
127
|
+
}
|
|
128
|
+
const token = readPrivateToken();
|
|
129
|
+
if (!token) return { error: 'unavailable' };
|
|
130
|
+
|
|
131
|
+
const context = recentContext(recentMessages);
|
|
132
|
+
if (context) {
|
|
133
|
+
// Priming is an optimization. A miss or timeout falls through to the same
|
|
134
|
+
// slow-path facet request; composing and sending never depend on it.
|
|
135
|
+
await post('/prime', { handle: normalizedHandle, context }, token, PRIME_TIMEOUT_MS, fetchImpl);
|
|
136
|
+
}
|
|
137
|
+
const facet = await post(
|
|
138
|
+
'/facet',
|
|
139
|
+
{ handle: normalizedHandle, draft: activeDraft },
|
|
140
|
+
token,
|
|
141
|
+
90_000,
|
|
142
|
+
fetchImpl,
|
|
143
|
+
);
|
|
144
|
+
if (facet === UNAVAILABLE) return { error: 'unavailable' };
|
|
145
|
+
if (!facet || facet.silence) return { silence: true };
|
|
146
|
+
return { facet };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
MIND_ORIGIN,
|
|
151
|
+
MAX_DRAFT_BYTES,
|
|
152
|
+
MAX_CONTEXT_BYTES,
|
|
153
|
+
MAX_RECENT_MESSAGES,
|
|
154
|
+
PRIME_TIMEOUT_MS,
|
|
155
|
+
tokenPath,
|
|
156
|
+
readPrivateToken,
|
|
157
|
+
isAvailable,
|
|
158
|
+
recentContext,
|
|
159
|
+
ask,
|
|
160
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Real-provider reflect canary (vibe-mcp#24 acceptance): run vibe_reflect
|
|
4
|
+
* against the ACTUAL VibeStats executable named by VIBE_STATS_CLI and prove
|
|
5
|
+
* the integration — shape, provenance, and privacy (this canary prints NO
|
|
6
|
+
* reflection content, only verdicts).
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
const cli = process.env.VIBE_STATS_CLI;
|
|
11
|
+
if (!cli || !existsSync(cli)) {
|
|
12
|
+
console.log('SKIP: set VIBE_STATS_CLI to the real vibestats executable');
|
|
13
|
+
process.exit(2);
|
|
14
|
+
}
|
|
15
|
+
const { createRequire } = await import('node:module');
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const reflect = require('../tools/reflect.js');
|
|
18
|
+
|
|
19
|
+
const res = await reflect.handler({});
|
|
20
|
+
const d = res.data || {};
|
|
21
|
+
// The canary certifies INTEGRATION: only a real reflection is a PASS
|
|
22
|
+
// (review round 2: no_data must not pass vacuously — it exits 3, honest but
|
|
23
|
+
// uncertified, so CI on a data-less machine cannot green-wash the contract).
|
|
24
|
+
if (d.outcome === 'no_data') {
|
|
25
|
+
console.log(`NO_DATA: real provider ran but this machine has no /insights data — integration NOT certified here`);
|
|
26
|
+
process.exit(3);
|
|
27
|
+
}
|
|
28
|
+
const problems = [];
|
|
29
|
+
if (d.outcome !== 'reflection') problems.push(`outcome ${d.outcome}, wanted reflection`);
|
|
30
|
+
if (typeof d.archetype !== 'string' || !d.archetype.trim()) problems.push('no archetype string');
|
|
31
|
+
if (!d.provenance || !d.provenance.version) problems.push('no provider version provenance');
|
|
32
|
+
if (!/private reflection/.test(res.display)) problems.push('display missing private framing');
|
|
33
|
+
const verdict = problems.length ? 'FAIL' : 'PASS';
|
|
34
|
+
console.log(`${verdict}: real provider ${cli.split('/').slice(-2).join('/')} · archetype=[${d.archetype ? 'present, not printed' : 'absent'}] · version=${d.provenance?.version || 'n/a'}${problems.length ? ' · ' + problems.join(' | ') : ''}`);
|
|
35
|
+
process.exit(problems.length ? 1 : 0);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runtime-kernel canary (epic #329 acceptance 1 + 3): boot the REAL MCP
|
|
4
|
+
* server over stdio under Claude Code and Codex host environments; verify
|
|
5
|
+
* the four verbs + manifest register and every capability reports a legal,
|
|
6
|
+
* reasoned state. Machine-verifiable; exits non-zero on any dishonesty.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join, dirname } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const VERBS = ['vibe_capabilities', 'vibe_remember', 'vibe_reflect', 'vibe_call', 'vibe_dm', 'vibe_inbox', 'vibe_reply'];
|
|
16
|
+
const LEGAL = new Set(['granted', 'available', 'off', 'unavailable']);
|
|
17
|
+
|
|
18
|
+
function rpc(child, msg) {
|
|
19
|
+
child.stdin.write(JSON.stringify(msg) + '\n');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function runHost(name, env) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const home = mkdtempSync(join(tmpdir(), `verb-canary-${name}-`));
|
|
25
|
+
// A synthetic signed-in principal (same shape the test suite uses) so the
|
|
26
|
+
// canary sees the POST-auth surface; nothing here can reach the network.
|
|
27
|
+
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
28
|
+
writeFileSync(join(home, 'config.json'), JSON.stringify({
|
|
29
|
+
username: 'canary',
|
|
30
|
+
authMethod: 'github',
|
|
31
|
+
authToken: `h.${b64({ sub: 'canary', exp: Math.floor(Date.now() / 1000) + 86400 })}.sig`,
|
|
32
|
+
one_liner: 'verb canary',
|
|
33
|
+
}));
|
|
34
|
+
const child = spawn('node', [join(ROOT, 'index.js')], {
|
|
35
|
+
env: { ...process.env, ...env, VIBE_HOME: home, VIBE_SETUP_NO_AUTORUN: '1' },
|
|
36
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
37
|
+
});
|
|
38
|
+
const results = {};
|
|
39
|
+
let buffer = '';
|
|
40
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error(`${name}: timeout`)); }, 15000);
|
|
41
|
+
child.stdout.on('data', (d) => {
|
|
42
|
+
buffer += d.toString();
|
|
43
|
+
let idx;
|
|
44
|
+
while ((idx = buffer.indexOf('\n')) >= 0) {
|
|
45
|
+
const line = buffer.slice(0, idx); buffer = buffer.slice(idx + 1);
|
|
46
|
+
if (!line.trim()) continue;
|
|
47
|
+
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
48
|
+
if (msg.id === 1) {
|
|
49
|
+
rpc(child, { jsonrpc: '2.0', id: 2, method: 'tools/list' });
|
|
50
|
+
} else if (msg.id === 2) {
|
|
51
|
+
results.tools = (msg.result?.tools || []).map((t) => t.name);
|
|
52
|
+
rpc(child, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'vibe_capabilities', arguments: {} } });
|
|
53
|
+
} else if (msg.id === 3) {
|
|
54
|
+
const text = msg.result?.content?.[0]?.text || '';
|
|
55
|
+
results.manifestText = text;
|
|
56
|
+
clearTimeout(timer); child.kill();
|
|
57
|
+
resolve(results);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
child.on('error', reject);
|
|
62
|
+
rpc(child, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name, version: '1' } } });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Deterministic expectations (review P2: no vacuous matching): the canary
|
|
67
|
+
// pins each verb to ONE expected state on any machine — off flags make
|
|
68
|
+
// remember/reflect/call machine-independent; the synthetic credential makes
|
|
69
|
+
// message granted. Each verb must appear exactly once WITH a reason.
|
|
70
|
+
const CONTROLLED_ENV = { VIBE_REMEMBER: 'off', VIBE_REFLECT: 'off', VIBE_CALL: 'off' };
|
|
71
|
+
const EXPECTED = { remember: 'off', reflect: 'off', call: 'off', message: 'granted' };
|
|
72
|
+
|
|
73
|
+
const hosts = {
|
|
74
|
+
'claude-code': { CLAUDECODE: '1', ...CONTROLLED_ENV },
|
|
75
|
+
codex: { CODEX_HOME: mkdtempSync(join(tmpdir(), 'codex-home-')), ...CONTROLLED_ENV },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let failed = false;
|
|
79
|
+
for (const [name, env] of Object.entries(hosts)) {
|
|
80
|
+
const r = await runHost(name, env);
|
|
81
|
+
const missing = VERBS.filter((v) => !r.tools.includes(v));
|
|
82
|
+
const states = [...r.manifestText.matchAll(/(remember|reflect|message|call) — (\w+) · (.+)/g)];
|
|
83
|
+
const seen = new Map(states.map(([, verb, state, why]) => [verb, { state, why }]));
|
|
84
|
+
const problems = [];
|
|
85
|
+
if (states.length !== 4) problems.push(`expected 4 state lines, saw ${states.length}`);
|
|
86
|
+
for (const [verb, want] of Object.entries(EXPECTED)) {
|
|
87
|
+
const got = seen.get(verb);
|
|
88
|
+
if (!got) problems.push(`${verb}: missing`);
|
|
89
|
+
else {
|
|
90
|
+
if (!LEGAL.has(got.state)) problems.push(`${verb}: illegal state ${got.state}`);
|
|
91
|
+
if (got.state !== want) problems.push(`${verb}: expected ${want}, got ${got.state}`);
|
|
92
|
+
if (!got.why || got.why.trim().length < 5) problems.push(`${verb}: no reason given`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (missing.length) problems.push(`missing tools: ${missing}`);
|
|
96
|
+
if (r.tools.includes('vibe_mind')) problems.push('retired vibe_mind still listed');
|
|
97
|
+
if (r.tools.includes('vibe_doctor')) problems.push('admin doctor leaked into default list');
|
|
98
|
+
const ok = problems.length === 0;
|
|
99
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}: ${r.tools.length} tools; ${[...seen].map(([v, g]) => `${v}=${g.state}`).join(' ')}${ok ? '' : '; PROBLEMS: ' + problems.join(' | ')}`);
|
|
100
|
+
if (!ok) failed = true;
|
|
101
|
+
}
|
|
102
|
+
process.exit(failed ? 1 : 0);
|